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
- You operate a resource hierarchy (org → folders → projects) and want to enforce security/compliance guardrails (no external IPs, OS Login required, restricted regions, no service-account key creation) centrally rather than per project.
- You are building a landing zone / foundation and need policies version-controlled, peer-reviewed, and promoted through environments via Git refs.
- You need list constraints with allow/deny semantics — e.g. restrict
gcp.resourceLocationstoin:asia-south1-locations, or piniam.allowedPolicyMemberDomainsto your Cloud Identity customer ID. - You want to dry-run a constraint (observe violations in audit logs without blocking) before enforcing it on an active folder.
- You need per-resource exceptions via CEL conditions (e.g. enforce a boolean everywhere except a tagged break-glass project) without disabling the policy globally.
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 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/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
- Dry-run before you enforce on live nodes. Always wire a new constraint through
dry_run_rulesfirst and inspectorgpolicyViolationsPreview/ audit-log entries; flipping adeny_alllist constraint or a strictgcp.resourceLocationson an active folder can instantly block resource creation across every descendant project. - Enforce one constraint per module instance and one parent per call. The module deliberately accepts a single scope (org/folder/project) and constraint so each guardrail has its own plan, etag, and review surface — never overload
parentresolution by passing multiple scope inputs. - Prefer folders over org-wide for blast-radius control. Attach policies as low in the hierarchy as the requirement allows and rely on inheritance; reserve org-node policies for non-negotiable security baselines (key creation, external IP, allowed domains) so a bad change can’t take down unrelated business units.
- Scope exceptions with CEL conditions and resource tags, not by disabling the policy. Use a conditional
enforce = "FALSE"rule bound to aresource.matchTagId(...)expression for break-glass projects instead of removing or weakening the policy globally. - Pin module and provider versions; promote via Git refs. Consume the module by tagged
?ref=vX.Y.Zand keephashicorp/google ~> 5.0pinned inversions.tfso a guardrail change is an explicit, reviewable, environment-promoted release — not a silent floating update. - Name and govern policies consistently, and watch
etagfor drift. Let the module derivenamedeterministically fromparent+constraint, surface theetagoutput into your drift detection, and alert on out-of-band edits since console changes to org policy can silently override Terraform-managed enforcement.