~/2026/05/13/google-cloud-organization-policy-constraints-that-actually-matter.md

Google Cloud: Organization Policy Constraints That Actually Matter

---
author: 
date: 
read: 7 min
in:   [engineering]
tags: [gcloud]
---

$ grep -n '^#' post.md

Google Cloud ships a long catalog of Organization Policy constraints, and the temptation the first time you're handed org-level admin is to enable a defensible-looking chunk of them and call it a security baseline. I did that once, broke three teams' CI pipelines in the same afternoon, and spent the following weeks learning which constraints actually stop a bad day and which ones just make an audit report look thorough.

Two things have changed since then that shape the advice below. First, Google now enforces a small security baseline on every organization created on or after May 3, 2024. Second, most of the constraints worth caring about now have managed versions (iam.managed.*, compute.managed.*, sql.managed.*), and those support dry-run mode and tag-based exemptions. The legacy versions mostly don't.

Know what you already have

If your organization is new enough, the baseline is already on. Google's secure-by-default page lists exactly seven constraints enforced on organizations created on or after May 3, 2024 (and possibly on some created between February and April 2024):

ConstraintEffect
iam.managed.disableServiceAccountKeyCreationNo new service account keys
iam.disableServiceAccountKeyUploadNo uploading external public keys to service accounts
iam.automaticIamGrantsForDefaultServiceAccountsDefault service accounts don't get Editor automatically
iam.allowedPolicyMemberDomainsOnly identities from your own organization can be granted roles
essentialcontacts.managed.allowedContactDomainsEssential Contacts limited to your domains
compute.managed.restrictProtocolForwardingCreationForTypesProtocol forwarding restricted to internal IPs
storage.uniformBucketLevelAccessBuckets can't use per-object ACLs

Older organizations have none of these unless someone set them. Either way, check the effective state before adding anything:

bash
gcloud org-policies list --organization=123456789012

gcloud org-policies describe iam.managed.disableServiceAccountKeyCreation \
  --effective --organization=123456789012

The handful that earn their keep

Service account key creation. iam.managed.disableServiceAccountKeyCreation stops the most common way a service account credential ends up committed to a repository or pasted into a chat, because there's no long-lived key to leak in the first place. Workloads use an attached service account or Workload Identity Federation instead. If you're on an older org still using the legacy iam.disableServiceAccountKeyCreation, move to the managed one: Google recommends the managed equivalents for more flexible policies and better insight from Policy Intelligence tools.

yaml
# disable-sa-keys.yaml
name: organizations/123456789012/policies/iam.managed.disableServiceAccountKeyCreation
spec:
  rules:
  - enforce: true

External IPs on VMs. Someone forgets --no-address on a one-off VM, and the constraint refuses the create instead of leaving a box exposed until the next audit. The managed compute.managed.vmExternalIpAccess is a boolean: when enforced, it denies creating or updating VMs with IPv4 external addresses (it doesn't touch IPv6). Exceptions such as a bastion host are handled with tags rather than an instance list. The legacy compute.vmExternalIpAccess is a list constraint that names each allowed instance as projects/PROJECT_ID/zones/ZONE/instances/INSTANCE, which turns every new exception into a policy edit.

Public IP on Cloud SQL. sql.managed.restrictPublicIp (legacy: sql.restrictPublicIp) closes the same gap for databases, where a public IP is a much bigger blast radius than a stray VM. Both versions are explicitly not retroactive: instances that already have a public IP keep working, so pair the constraint with a one-time sweep. One side effect the Cloud SQL docs call out: gcloud sql connect stops working, because it connects over the public IP. Teams need the Cloud SQL Auth Proxy over private IP instead.

Public buckets. storage.publicAccessPrevention is the one on this list that is retroactive. It blocks ACLs and IAM grants to allUsers and allAuthenticatedUsers, and the constraint description says public access is revoked for existing buckets and objects once it's enabled. That's exactly what you want, but it's also why this one absolutely goes through the rollout below. Somebody's static site or public download bucket will stop working.

The ones that look good and do less

iam.allowedPolicyMemberDomains gets enabled everywhere because it reads well in a compliance deck. It stops identities outside your organization's Workspace or Cloud Identity account from being granted roles. It's worth having, and new organizations get it by default, but in my experience it mostly fires on typo'd email addresses during role grants. It's further down the list than its audit-friendly name suggests. If you do set it, use the managed iam.managed.allowedPolicyMembers, which takes your organization's principal set as a parameter:

yaml
name: organizations/123456789012/policies/iam.managed.allowedPolicyMembers
spec:
  rules:
  - enforce: true
    parameters:
      allowedPrincipalSets:
      - //cloudresourcemanager.googleapis.com/organizations/123456789012

Read the fine print before enforcing it. The constraint description warns that it can block folder creation (because of automatic Folder Admin and Folder Editor grants) and project creation (because of the automatic Owner grant) if those principals aren't covered.

API restriction is the other one that sounds stronger than it is. People reach for serviceuser.services, but it only works as a deny list, and it can only restrict three services: compute.googleapis.com, deploymentmanager.googleapis.com, and dns.googleapis.com. The real allowlist is gcp.restrictServiceUsage (Restrict Resource Service Usage). It works, and it supports dry run, but in a fast-moving engineering org it mostly generates tickets from teams that needed one more API for a legitimate reason. It doesn't meaningfully shrink the attack surface if IAM is already scoped correctly. I use it on regulated folders and nowhere else.

The rollout order that avoids breaking things

The lesson from that first afternoon: never flip a constraint org-wide as your first move. Every constraint I've kept in production went through the same three steps.

1. Dry run. Put the policy in dryRunSpec and watch the violations against real traffic for at least a week. Dry run only works with custom constraints, managed constraints, and a few legacy ones (restrict service usage, restrict endpoint usage, TLS versions, TLS cipher suites). Anything else returns an error, which is another reason to use the managed versions.

yaml
# vm-external-ip.dryrun.yaml
name: organizations/123456789012/policies/compute.managed.vmExternalIpAccess
dryRunSpec:
  rules:
  - enforce: true
bash
gcloud org-policies set-policy vm-external-ip.dryrun.yaml --update-mask=dryRunSpec

Dry-run results land in the policy audit log (cloudaudit.googleapis.com%2Fpolicy). This Logs Explorer query shows only the requests the policy would have denied:

text
logName:"cloudaudit.googleapis.com%2Fpolicy"
protoPayload.metadata.dryRunResult="DENIED"
protoPayload.metadata.liveResult="ALLOWED"

2. Tag the legitimate exceptions. Bastion hosts, NAT instances, and a handful of demo environments all have real reasons for an external IP. Skipping this step guarantees a break. With a managed boolean constraint, the exception is a tag plus a conditional rule. Create the tag and bind it to the projects that are allowed. A project binding exempts every VM in that project, so for a lone bastion in a shared project, bind the tag to the instance instead:

bash
gcloud resource-manager tags keys create external-ip --parent=organizations/123456789012
gcloud resource-manager tags values create allowed --parent=123456789012/external-ip

gcloud resource-manager tags bindings create \
  --tag-value=123456789012/external-ip/allowed \
  --parent=//cloudresourcemanager.googleapis.com/projects/network-edge-prod

3. Enforce folder by folder. Start with a low-stakes folder (a sandbox or dev folder, never the one with the CI service accounts in it). Then move the policy up the hierarchy as the violation log goes quiet. A policy with a conditional rule must also have at least one unconditional rule, and for a boolean constraint the conditional rule has to be the opposite of the default:

yaml
# vm-external-ip.yaml
name: folders/456789012345/policies/compute.managed.vmExternalIpAccess
spec:
  rules:
  - condition:
      title: external-ip-allowed
      expression: resource.matchTag("123456789012/external-ip", "allowed")
    enforce: false
  - enforce: true
bash
gcloud org-policies set-policy vm-external-ip.yaml --update-mask=spec

Changes can take up to 15 minutes to be enforced, so don't read a successful VM create in the first few minutes as proof the policy is broken.

Where the hierarchy actually bites

The failure mode that costs the most debugging time isn't a constraint being wrong. It's a constraint set at the wrong node in the resource hierarchy and inherited somewhere nobody expected. A policy set on the organization applies to every folder and project below it unless something downstream sets its own policy. So when a team reports a violation that "shouldn't be possible," I don't start with the project's own policy. I start with the effective one, because the constraint that's actually biting them is almost always three folders up from where they're looking:

bash
gcloud org-policies describe compute.managed.vmExternalIpAccess \
  --effective --project=team-a-dev

The two cleanup commands are easy to confuse. gcloud org-policies delete CONSTRAINT --project=PROJECT_ID removes the project's own policy, so the project goes back to whatever it inherits. gcloud org-policies reset CONSTRAINT --project=PROJECT_ID does something different: it resets the policy to the constraint's default, which for most constraints means not enforced, whatever the parent says.

To see the whole estate at once, this script prints the effective enforcement of the constraints above for every project the caller can see. It needs roles/orgpolicy.policyViewer at the organization plus resourcemanager.projects.list, for example Browser (roles/browser) on the organization. It uses nothing beyond gcloud and Bash:

bash
#!/usr/bin/env bash
# effective-org-policies.sh
# Prints the effective enforcement of key boolean constraints for every
# project visible to the caller, as CSV on stdout.
# Usage: ./effective-org-policies.sh [PROJECT_FILTER] > effective.csv
#   PROJECT_FILTER is an optional gcloud --filter, e.g. "parent.id=456789012345"
set -euo pipefail

ERR_FILE=$(mktemp)
trap 'rm -f "$ERR_FILE"' EXIT

CONSTRAINTS=(
  iam.managed.disableServiceAccountKeyCreation
  iam.disableServiceAccountKeyCreation
  compute.managed.vmExternalIpAccess
  sql.managed.restrictPublicIp
  sql.restrictPublicIp
  storage.publicAccessPrevention
  storage.uniformBucketLevelAccess
)
FILTER="${1:-lifecycleState=ACTIVE}"

printf 'project,%s\n' "$(IFS=,; echo "${CONSTRAINTS[*]}")"

for project in $(gcloud projects list --filter="$FILTER" --format='value(projectId)'); do
  row="$project"
  for constraint in "${CONSTRAINTS[@]}"; do
    # spec.rules may hold conditional rules; list every enforce value
    if value=$(gcloud org-policies describe "$constraint" --effective \
        --project="$project" --format='value(spec.rules[].enforce)' 2>"$ERR_FILE"); then
      [[ -z "$value" ]] && value="unset"
    elif grep -q NOT_FOUND "$ERR_FILE"; then
      value="unset"   # no policy set anywhere in the hierarchy
    else
      value="error"   # permission denied, API disabled, and so on
    fi
    row+=",${value//;/|}"
  done
  echo "$row"
done

Sample output:

text
project,iam.managed.disableServiceAccountKeyCreation,iam.disableServiceAccountKeyCreation,compute.managed.vmExternalIpAccess,sql.managed.restrictPublicIp,sql.restrictPublicIp,storage.publicAccessPrevention,storage.uniformBucketLevelAccess
network-edge-prod,True,unset,False|True,True,unset,True,True
team-a-dev,True,unset,True,True,unset,unset,True
legacy-reporting,unset,unset,unset,unset,unset,unset,unset

A value like False|True means a conditional rule is in play (here, the tag exemption), unset on every column is the project to go look at first, and error means the lookup itself failed (usually permissions). For "what would break if I enforced this," Policy Simulator for organization policies answers the question before you commit. It needs roles/policysimulator.orgPolicyAdmin, and it handles custom and managed constraints but not legacy ones.

The honest summary

A handful of constraints scoped to real credential and exposure risks, rolled out gradually with dry-run data, does more for your security posture than enabling most of the catalog at once. The value is in the few that close a specific, recurring failure mode (keys, external IPs, public databases, public buckets), not in the count of constraints turned on. If you want to find the service account keys that already exist before you lock creation down, the service account key rotation script and the organization-wide IAM audit are the companion pieces.

References