Google Cloud IAM explained: hierarchy, roles, and Conditions
For developers who need to decide how to grant access in Google Cloud without creating a future cleanup project. This guide explains the resource hierarchy, the difference between basic and predefined roles, and what IAM Conditions actually change in policy evaluation, with concrete gcloud examples and failure modes.
TL;DR — In Google Cloud IAM, access is mostly about where you bind a role in the resource hierarchy and which role type you choose. The safest default is: bind the narrowest predefined role at the lowest resource level that works, and use IAM Conditions only when you need context-aware limits like time windows or resource-name scoping. Reading time: ~7 min
What it is and where it sits
Google Cloud IAM is the authorization layer that sits between an authenticated principal and a Google Cloud API call. The practical questions it answers are: "does this caller have permission X on resource Y?" and "does that permission apply here, under current context?"
For a developer, the architecture context matters more than the textbook definition:
- Authentication usually comes from a user account, service account, workforce identity, or workload identity federation.
- The caller then hits a Google Cloud control-plane API: Cloud Storage, BigQuery, Compute Engine, Secret Manager, etc.
- That service asks IAM policy evaluation whether the principal has the needed permission on the target resource.
- IAM walks up the resource hierarchy to collect applicable allow bindings.
- If a binding has an IAM Condition, the expression must evaluate true for the request context.
What it replaced, conceptually: older per-product ACL-style thinking. Some products still have product-specific access models, but for most day-to-day cloud authorization in GCP, IAM policy bindings at org/folder/project/resource level are the main control plane.
A typical flow looks like this:
[user / service account / federated identity]
|
| OAuth2 / STS / service account token
v
[Google Cloud API endpoint]
|
| "Need permission resourcemanager.projects.get" or
| "storage.objects.get" on target resource
v
[IAM policy evaluation]
|
| collect bindings from:
| org -> folder(s) -> project -> resource
| evaluate role permissions
| evaluate condition expression if present
v
ALLOW or PERMISSION_DENIED
The hierarchy is the part people underestimate:
- Organization: top-level for a company domain.
- Folder: optional grouping for teams/environments/business units.
- Project: billing, quotas, and a common IAM boundary.
- Resource: service-specific object like a bucket, secret, dataset, instance, topic.
Bindings inherit downward. If you grant a role at the folder, every project and eligible child resource under that folder can inherit it. That inheritance is the biggest source of both convenience and accidental overreach.
How it actually works
Walk one realistic example: a CI service account should read secrets from Secret Manager, but only in one project, only for secrets whose names start with ci-, and only until the end of the quarter.
Step 1: Pick the resource level
The service account only needs access in one project, so bind at the project level or directly on each secret. Project-level is easier to manage; per-secret is narrower but more operationally noisy.
We will bind at the project level and use a condition to narrow to matching secrets.
Step 2: Pick the role type
Avoid the basic roles:
roles/viewerroles/editorroles/owner
These are broad, cross-product, and usually larger than you intend. roles/editor in particular is the classic footgun because it grants write access across many services.
Instead use a predefined role. For Secret Manager read access, the common choice is roles/secretmanager.secretAccessor.
You can inspect what a role actually contains:
gcloud iam roles describe roles/secretmanager.secretAccessor
Typical output shape:
name: roles/secretmanager.secretAccessor
title: Secret Manager Secret Accessor
description: Access the payload of secrets.
includedPermissions:
- secretmanager.versions.access
stage: GA
That output is the point: predefined roles are bundles of explicit permissions. You should inspect them when deciding whether they are narrow enough.
Step 3: Add a conditional binding
Bind the role on the project, but only when the target resource name matches and the request time is before a cutoff.
gcloud projects add-iam-policy-binding my-app-prod \
--member="serviceAccount:ci-reader@my-app-prod.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor" \
--condition='title=ci-secrets-q2,expression=resource.name.startsWith("projects/123456789/secrets/ci-") && request.time < timestamp("2026-07-01T00:00:00Z"),description=CI can read ci-* secrets until end of Q2'
What changed because of the condition: the role is no longer a blanket allow for every resource covered by the binding scope. The binding only counts if the expression evaluates true for the current request context.
Step 4: What happens on an actual API call
Suppose the CI job runs:
gcloud secrets versions access latest \
--secret=ci-docker-password \
--project=my-app-prod
Secret Manager needs secretmanager.versions.access on projects/123456789/secrets/ci-docker-password/versions/latest.
IAM evaluation is effectively:
- Identify principal:
serviceAccount:ci-reader@my-app-prod.iam.gserviceaccount.com - Collect applicable bindings from project and ancestors.
- Find bindings that include a role containing
secretmanager.versions.access. - For each matching binding with a condition, evaluate the expression.
- Allow if at least one allow binding matches and no higher-priority deny policy blocks it.
If the secret is ci-docker-password and the time is before July 1, access succeeds.
If the CI job tries a different secret:
gcloud secrets versions access latest \
--secret=prod-db-root-password \
--project=my-app-prod
You should expect a failure like:
ERROR: (gcloud.secrets.versions.access) PERMISSION_DENIED: Permission 'secretmanager.versions.access' denied for resource 'projects/123456789/secrets/prod-db-root-password/versions/latest' (or it may not exist).
That message is annoyingly ambiguous by design: missing permission and missing resource are often collapsed into the same response shape. For diagnosis, check both the IAM policy and the actual resource path.
Step 5: What IAM Conditions do not change
Conditions do not create new permissions. They only narrow when an existing binding applies.
Conditions also do not change inheritance rules. A conditional binding at a folder still inherits to child projects; the condition just gates applicability per request.
And they do not replace role design. If you bind roles/editor with a condition, you still gave a huge permission set; you only restricted when it applies. That is often still too much.
When to use it (and when not to)
Use the hierarchy, role choice, and conditions as separate levers:
- Hierarchy answers: where should this access apply?
- Role type answers: what permissions are included?
- Conditions answer: under what context should the binding count?
| Scenario | Recommendation |
|---|---|
| Team needs read-only access to one project's logs and metrics | Bind predefined viewer-style roles for the specific products at the project level; do not use basic roles/viewer unless you truly want broad read access across the project |
| CI service account needs one API capability in one project | Bind the narrowest predefined role to the service account at the project or resource level |
| Temporary contractor access for a migration window | Use a predefined role plus an IAM Condition with request.time < timestamp(...) |
| Access should apply only to resources with a naming convention | Use a condition if the target service supports the needed resource attributes; otherwise bind directly on each resource |
| Small sandbox project, no org/folder structure | Project-level bindings are usually enough; don't force folders just because the hierarchy exists |
| You want to "just let devs do stuff" quickly | You probably don't need basic roles/editor; create a group and assign a few predefined roles instead |
| You need one-off exceptions on dozens of resources | You probably don't need Conditions if direct resource-level bindings are clearer and easier to audit |
| You need deny-style guardrails | IAM Conditions are not the same as deny policies; use deny policies or org policies where appropriate |
You probably don't need IAM Conditions if:
- a plain resource-level binding solves it cleanly;
- the condition expression will be harder to understand than the access rule itself;
- your team does not have good policy review discipline;
- the service you care about does not expose the attributes you want to test in conditions.
Trade-offs
Every IAM design choice buys something and costs something.
| Benefit | Cost |
|---|---|
| Binding at org/folder reduces repetition | Inheritance increases blast radius; one bad binding affects many projects |
| Project-level bindings are easy to manage | They are often broader than necessary compared to resource-level bindings |
| Predefined roles are safer than basic roles | You may need multiple roles to cover a workflow, which increases policy size and troubleshooting time |
| Basic roles are fast for prototypes | They become long-term privilege debt and are painful to audit |
| IAM Conditions let you express temporary or contextual access | Conditions add cognitive load, harder reviews, and more subtle failures during incidents |
| Conditions can reduce the number of per-resource bindings | The policy becomes less obvious because the real scope is hidden in expressions |
| Narrow roles improve least privilege | More policy objects to manage; more chance of "why does this API still fail?" debugging |
Latency and money are usually not the main costs here. IAM evaluation is part of normal control-plane request handling; the bigger costs are operational: debugging PERMISSION_DENIED, policy sprawl, and accidental inheritance.
Lock-in is real. IAM Conditions use Google's expression model and resource naming semantics. If you build your access model around provider-specific conditions, portability drops.
In practice
Example 1: Inspect inherited policy before changing anything
⚠️ Changing IAM on a folder or project can cause immediate production impact. If you remove a binding used by CI, deploys may fail within seconds.
gcloud projects get-iam-policy my-app-prod \
--format=json > /tmp/my-app-prod-iam.json
jq '.bindings[] | {role, members, condition}' /tmp/my-app-prod-iam.json
This dumps the current project policy and prints each binding with any condition attached. The gotcha: get-iam-policy shows the policy on that resource, not a fully expanded inherited view from ancestors, so also inspect the parent folder/org if access seems to come from nowhere.
If you want to inspect a folder:
gcloud resource-manager folders get-iam-policy 456789012345 --format=json
Example 2: Replace a basic role with narrower predefined roles
gcloud projects remove-iam-policy-binding my-app-prod \
--member="group:devs@example.com" \
--role="roles/editor"
gcloud projects add-iam-policy-binding my-app-prod \
--member="group:devs@example.com" \
--role="roles/logging.viewer"
gcloud projects add-iam-policy-binding my-app-prod \
--member="group:devs@example.com" \
--role="roles/monitoring.viewer"
gcloud projects add-iam-policy-binding my-app-prod \
--member="group:devs@example.com" \
--role="roles/cloudsql.client"
This removes broad write access and replaces it with a smaller set aligned to a common developer workflow. The gotcha: removing roles/editor can break hidden dependencies like artifact pushes, secret reads, or deploy actions; run this first in a non-prod project and watch for PERMISSION_DENIED errors in CI logs.
Typical failure shape after over-tightening:
ERROR: (gcloud.run.deploy) PERMISSION_DENIED: Permission 'iam.serviceAccounts.actAs' denied on service account deployer@my-app-prod.iam.gserviceaccount.com
That error tells you the missing permission, which usually maps to a specific predefined role rather than a reason to re-add roles/editor.
Example 3: Add a time-bounded conditional binding from a policy file
{
"bindings": [
{
"role": "roles/secretmanager.secretAccessor",
"members": [
"serviceAccount:ci-reader@my-app-prod.iam.gserviceaccount.com"
],
"condition": {
"title": "ci-secrets-q2",
"description": "CI can read ci-* secrets until end of Q2",
"expression": "resource.name.startsWith(\"projects/123456789/secrets/ci-\") && request.time < timestamp(\"2026-07-01T00:00:00Z\")"
}
}
],
"etag": "BwXabc123=",
"version": 3
}
gcloud projects set-iam-policy my-app-prod policy.json
This applies a full IAM policy document with a condition; version: 3 is required for conditional bindings. The gotcha: set-iam-policy replaces the whole policy on that resource, so start from get-iam-policy, edit carefully, and preserve the etag to avoid clobbering concurrent changes.
A bad update often fails with a shape like:
ERROR: (gcloud.projects.set-iam-policy) INVALID_ARGUMENT: Policy cannot contain both condition and version 1. Use policy version 3.
or:
ERROR: (gcloud.projects.set-iam-policy) ABORTED: There were concurrent policy changes. Please retry the whole read-modify-write with the latest etag.
Further reading
- Google Cloud IAM documentation: "Resource hierarchy"
- Google Cloud IAM documentation: "Understanding roles"
- Google Cloud IAM documentation: "IAM Conditions overview"
- Google Cloud IAM documentation: "Policy types"
- Google Cloud documentation: "Troubleshooting permission errors"
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI