~/2026/05/13/google-cloud-organization-policy-constraints-that-actually-matter.md
Google Cloud: Organization Policy Constraints That Actually Matter
--- author: Tom Lasswell 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):
| Constraint | Effect |
|---|---|
iam.managed.disableServiceAccountKeyCreation | No new service account keys |
iam.disableServiceAccountKeyUpload | No uploading external public keys to service accounts |
iam.automaticIamGrantsForDefaultServiceAccounts | Default service accounts don't get Editor automatically |
iam.allowedPolicyMemberDomains | Only identities from your own organization can be granted roles |
essentialcontacts.managed.allowedContactDomains | Essential Contacts limited to your domains |
compute.managed.restrictProtocolForwardingCreationForTypes | Protocol forwarding restricted to internal IPs |
storage.uniformBucketLevelAccess | Buckets 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:
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.
# 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:
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.
# vm-external-ip.dryrun.yaml
name: organizations/123456789012/policies/compute.managed.vmExternalIpAccess
dryRunSpec:
rules:
- enforce: true
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:
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:
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:
# 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
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:
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:
#!/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:
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
- Secure-by-default organization policies
- Organization policy constraints reference
- Create organization policies (YAML, describe, reset, delete)
- Test organization policies in dry-run mode
- Scope organization policies with tags
- Create and manage tags
- Restrict service account usage
- Restrict identities with domain-restricted sharing
- Policy Simulator for organization policies
- Cloud SQL organization policies