IaC GCP

Terraform Module: GCP Organization Policy — guardrails as code across your resource hierarchy

Quick take — Build a reusable Terraform module for google_org_policy_policy on hashicorp/google ~> 5.0 — enforce, merge, and conditionally exempt boolean and list constraints across orgs, folders, and projects. 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 "org_policy" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-org-policy?ref=v1.0.0"

  constraint = "..."  # Fully-qualified constraint name; must start with `const…
}

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

What this module is

Google Cloud’s Organization Policy Service lets you set centralized, inheritable restrictions on how resources can be configured anywhere in your resource hierarchy — the organization node, folders, and individual projects. A policy binds a constraint (for example constraints/compute.requireOsLogin, constraints/iam.disableServiceAccountKeyCreation, or constraints/gcp.resourceLocations) to one or more rules that either flip a boolean on/off or allow/deny a list of values. Children inherit a parent’s policy by default, and you decide whether a child may merge with the parent or reset and override it.

The modern resource for this is google_org_policy_policy. It replaced the legacy google_organization_policy / google_folder_organization_policy / google_project_organization_policy trio with a single resource that targets any node via a parent string and expresses everything — boolean enforcement, list allow/deny, enforce, allow_all/deny_all, inheritance via inherit_from_parent, and CEL-gated exceptions via condition — through a uniform spec.rules block. It also supports dry_run_spec so you can observe what a policy would deny (surfaced in audit logs) before you actually enforce it.

Wrapping this in a module matters because org policy is the single most powerful blast-radius control in a GCP landing zone, and it is unforgiving: a malformed gcp.resourceLocations rule or an accidental deny_all on iam.allowedPolicyMemberDomains can lock an entire folder out of creating resources. A module gives you one validated, reviewed, testable surface — boolean vs. list shape enforced by input validation, consistent parent formatting, dry-run by default in non-prod, and explicit conditional exemptions — instead of hand-written spec blocks scattered across stacks where one typo bricks a folder.

When to use it

Reach for the legacy single-constraint resources only when you are maintaining an old codebase that has not migrated; for anything new on provider 5.x, google_org_policy_policy is the correct primitive.

Module structure

terraform-module-gcp-org-policy/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_org_policy_policy, locals, parent resolution
├── variables.tf     # var-driven inputs with validation
├── outputs.tf       # id, name, etag, constraint, parent
└── README.md
# versions.tf
terraform {
  required_version = ">= 1.5.0"

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

locals {
  # google_org_policy_policy.parent accepts exactly one of:
  #   organizations/{org_id}, folders/{folder_id}, projects/{project_id}
  # We derive it from whichever scope input is set.
  parent = (
    var.organization_id != null ? "organizations/${var.organization_id}" :
    var.folder_id != null ? "folders/${var.folder_id}" :
    "projects/${var.project_id}"
  )

  # google_org_policy_policy.name must be "<parent>/policies/<constraint>".
  policy_name = "${local.parent}/policies/${var.constraint}"

  # Rules are authored as a list of objects in var.rules. We translate the
  # flexible input shape into the exact spec.rules schema the provider wants.
  spec_rules = [
    for r in var.rules : {
      enforce             = r.enforce
      allow_all           = r.allow_all
      deny_all            = r.deny_all
      condition           = r.condition
      values_allowed      = try(r.values.allowed, null)
      values_denied       = try(r.values.denied, null)
      inherit_from_parent = r.inherit_from_parent
    }
  ]
}

resource "google_org_policy_policy" "this" {
  name   = local.policy_name
  parent = local.parent

  spec {
    # When false, a parent's enforced policy still applies even if this policy
    # would not be evaluated; leave true so this policy's rules take effect.
    inherit_from_parent = var.inherit_from_parent

    # Setting reset = true clears any inherited policy and rules below are ignored.
    reset = var.reset

    dynamic "rules" {
      # If reset is true, the API rejects accompanying rules, so emit none.
      for_each = var.reset ? [] : local.spec_rules
      content {
        # Boolean constraints use enforce; list constraints use allow/deny.
        enforce   = rules.value.enforce
        allow_all = rules.value.allow_all
        deny_all  = rules.value.deny_all

        dynamic "values" {
          for_each = (
            rules.value.values_allowed != null ||
            rules.value.values_denied != null
          ) ? [1] : []
          content {
            allowed_values = rules.value.values_allowed
            denied_values  = rules.value.values_denied
          }
        }

        # CEL expression that scopes this rule to specific resources, e.g.
        # "resource.matchTagId('tagKeys/123', 'tagValues/456')".
        dynamic "condition" {
          for_each = rules.value.condition != null ? [rules.value.condition] : []
          content {
            title       = condition.value.title
            description  = condition.value.description
            expression  = condition.value.expression
            location    = condition.value.location
          }
        }
      }
    }
  }

  # Optional dry-run spec: violations are logged but NOT enforced. Use this to
  # validate a constraint on a live folder before turning on the live spec.
  dynamic "dry_run_spec" {
    for_each = var.dry_run_rules != null ? [1] : []
    content {
      inherit_from_parent = var.inherit_from_parent
      reset               = false

      dynamic "rules" {
        for_each = var.dry_run_rules
        content {
          enforce   = rules.value.enforce
          allow_all = rules.value.allow_all
          deny_all  = rules.value.deny_all

          dynamic "values" {
            for_each = (
              try(rules.value.values.allowed, null) != null ||
              try(rules.value.values.denied, null) != null
            ) ? [1] : []
            content {
              allowed_values = try(rules.value.values.allowed, null)
              denied_values  = try(rules.value.values.denied, null)
            }
          }
        }
      }
    }
  }
}
# variables.tf

variable "constraint" {
  description = "Full constraint name, e.g. constraints/compute.requireOsLogin or constraints/gcp.resourceLocations."
  type        = string

  validation {
    condition     = startswith(var.constraint, "constraints/")
    error_message = "constraint must be the fully-qualified name and start with 'constraints/'."
  }
}

variable "organization_id" {
  description = "Numeric organization ID to attach the policy to. Set exactly one of organization_id, folder_id, or project_id."
  type        = string
  default     = null
}

variable "folder_id" {
  description = "Numeric folder ID (without the 'folders/' prefix). Set exactly one of organization_id, folder_id, or project_id."
  type        = string
  default     = null
}

variable "project_id" {
  description = "Project ID or number. Set exactly one of organization_id, folder_id, or project_id."
  type        = string
  default     = null

  validation {
    condition = length(compact([
      var.organization_id, var.folder_id, var.project_id
    ])) == 1
    error_message = "Exactly one of organization_id, folder_id, or project_id must be set."
  }
}

variable "inherit_from_parent" {
  description = "Whether this policy inherits and merges with the parent's policy. List constraints only; ignored for boolean constraints and when reset = true."
  type        = bool
  default     = false
}

variable "reset" {
  description = "If true, clears any inherited policy on this node and ignores 'rules'. Cannot be combined with rules."
  type        = bool
  default     = false
}

variable "rules" {
  description = <<-EOT
    Live enforcement rules. For BOOLEAN constraints, set 'enforce' ("TRUE"/"FALSE").
    For LIST constraints, set allow_all/deny_all ("TRUE") OR values.allowed/values.denied.
    'condition' scopes a rule to tagged resources via a CEL expression.
  EOT
  type = list(object({
    enforce             = optional(string) # "TRUE" | "FALSE" (boolean constraints)
    allow_all           = optional(string) # "TRUE" (list constraints)
    deny_all            = optional(string) # "TRUE" (list constraints)
    inherit_from_parent = optional(bool)
    values = optional(object({
      allowed = optional(list(string))
      denied  = optional(list(string))
    }))
    condition = optional(object({
      title       = optional(string)
      description = optional(string)
      expression  = string
      location    = optional(string)
    }))
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.rules :
      r.enforce == null || contains(["TRUE", "FALSE"], r.enforce)
    ])
    error_message = "rules[*].enforce must be either \"TRUE\" or \"FALSE\"."
  }

  validation {
    condition = alltrue([
      for r in var.rules :
      !(r.enforce != null && (
        r.allow_all != null || r.deny_all != null ||
        try(r.values.allowed, null) != null || try(r.values.denied, null) != null
      ))
    ])
    error_message = "A rule cannot mix 'enforce' (boolean) with allow_all/deny_all/values (list) semantics."
  }

  validation {
    condition = alltrue([
      for r in var.rules :
      !(try(length(r.values.allowed), 0) > 0 && try(length(r.values.denied), 0) > 0)
    ])
    error_message = "A single rule cannot specify both values.allowed and values.denied."
  }
}

variable "dry_run_rules" {
  description = "Optional rules evaluated in dry-run mode (violations logged, not enforced). Same shape as 'rules'. Set null to disable."
  type = list(object({
    enforce   = optional(string)
    allow_all = optional(string)
    deny_all  = optional(string)
    values = optional(object({
      allowed = optional(list(string))
      denied  = optional(list(string))
    }))
  }))
  default = null
}
# outputs.tf

output "id" {
  description = "The fully-qualified policy ID (same as name): <parent>/policies/<constraint>."
  value       = google_org_policy_policy.this.id
}

output "name" {
  description = "The resource name of the org policy."
  value       = google_org_policy_policy.this.name
}

output "parent" {
  description = "The resolved parent the policy is attached to (organizations/, folders/, or projects/...)."
  value       = google_org_policy_policy.this.parent
}

output "constraint" {
  description = "The constraint this policy governs."
  value       = var.constraint
}

output "etag" {
  description = "Server-computed etag of the live spec, useful for detecting out-of-band drift."
  value       = google_org_policy_policy.this.spec[0].etag
}

How to use it

# Restrict resource locations on the "workloads" folder to India regions,
# but observe (dry-run) the OS Login requirement before enforcing it.

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

  constraint = "constraints/gcp.resourceLocations"
  folder_id  = "489210774521"

  # List constraint: deny everything except India multi-region location groups.
  rules = [
    {
      values = {
        allowed = ["in:asia-south1-locations", "in:asia-south2-locations"]
      }
    }
  ]
}

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

  constraint = "constraints/compute.requireOsLogin"
  folder_id  = "489210774521"

  # Boolean constraint enforced everywhere EXCEPT a tagged break-glass project.
  rules = [
    {
      enforce = "TRUE"
    },
    {
      enforce = "FALSE"
      condition = {
        title      = "exempt-breakglass"
        expression = "resource.matchTagId('tagKeys/281476526892', 'tagValues/281479463726')"
      }
    }
  ]

  # Validate impact in audit logs before any of the above blocks a workload.
  dry_run_rules = [
    { enforce = "TRUE" }
  ]
}

# Downstream reference: feed the policy name into a compliance dashboard /
# inventory module so the enforced guardrail is tracked alongside the folder.
resource "google_monitoring_dashboard" "guardrails" {
  dashboard_json = jsonencode({
    displayName = "Org Policy: ${module.org_policy_locations.constraint}"
    labels = {
      policy = replace(module.org_policy_locations.name, "/", "_")
    }
    gridLayout = { widgets = [] }
  })
}

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/org_policy/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  constraint = "..."
}

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

cd live/prod/org_policy && 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
constraint string Yes Fully-qualified constraint name; must start with constraints/.
organization_id string null No* Numeric org ID. Set exactly one scope input.
folder_id string null No* Numeric folder ID (no folders/ prefix). Set exactly one scope input.
project_id string null No* Project ID or number. Set exactly one scope input.
inherit_from_parent bool false No Merge with parent policy (list constraints); ignored for boolean and when reset = true.
reset bool false No Clear inherited policy on this node; cannot be combined with rules.
rules list(object) [] No Live rules: enforce for boolean, or allow_all/deny_all/values for list, with optional CEL condition.
dry_run_rules list(object) null No Same shape as rules, evaluated in dry-run mode (violations logged, not enforced).

* Exactly one of organization_id, folder_id, or project_id is required (enforced by validation).

Outputs

Name Description
id Fully-qualified policy ID (<parent>/policies/<constraint>).
name Resource name of the org policy.
parent Resolved parent node (organizations/, folders/, or projects/...).
constraint The constraint this policy governs.
etag Server-computed etag of the live spec, for out-of-band drift detection.

Enterprise scenario

A fintech running a GCP landing zone for an RBI-regulated workload uses this module in its foundation stack to pin constraints/gcp.resourceLocations to in:asia-south1-locations on the production folder, guaranteeing data residency in Mumbai. The same stack enforces constraints/iam.disableServiceAccountKeyCreation and constraints/compute.vmExternalIpAccess (deny-all) org-wide, with a single CEL-conditioned exemption for a tagged jump-host project so SREs retain break-glass SSH. New constraints are rolled out via dry_run_rules first; the platform team reviews Policy Analyzer / audit-log violations for a sprint, then promotes the module to the live rules set behind a tagged Git release.

Best practices

TerraformGCPOrganization PolicyModuleIaC
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