Why it matters
Google Cloud puts the access model and the resource model in the same tree. Nothing sits loose: every VM, bucket, dataset and topic has exactly one parent project, every project has at most one parent folder or organization, and an allow policy can be attached at any node on that path. A permission question on Google Cloud is therefore never what does this policy say but what does the union of the policies along this resource's ancestry say.
That single design choice explains most of what feels unusual about GCP IAM coming from AWS. There is no policy document attached to a user; there are bindings attached to resources. An ordinary policy written at the project cannot take back anything a folder handed out. And a grant made three levels up is invisible from the project console unless you go looking for it, which is how estates end up with a dozen people holding effective Editor on production and nobody able to say why.
This article is the concrete Google Cloud layer. The vendor-neutral model - principals, evaluation order, allow versus deny precedence, permission boundaries, RBAC against ABAC - is covered in Cloud IAM architecture, and the AWS condition-key equivalent in AWS IAM conditions.
Organization, folders, projects - the tree policies flow down
The hierarchy has four levels that matter. The organization is created when a Cloud Identity or Workspace domain is linked, and it is the only node that outranks every human administrator - grants made here are the ones that need a change-review process rather than a ticket. Folders nest below it and below each other, and they exist for exactly one reason: to be the attachment point for a policy that should apply to a set of projects without being repeated in each of them. Projects hold everything else. Resources - a bucket, a Pub/Sub topic, a BigQuery dataset, a KMS key ring - can carry their own policy for the services that support one.
Inheritance runs strictly downward and is purely additive. A binding at the organization contributes to the effective policy of every resource underneath it, forever, and there is no NotResource, no exclusion list, and no way for a project owner to shed it. The effective permission set for a caller is the union of every binding that matches them at every ancestor node, which means the answer to who can read this bucket is never in one place.
Design the folder layout around the policies you intend to write, not around the org chart. Teams reorganise; the set of projects that should share a production access boundary does not. A common shape is a top-level split by environment, with team folders beneath, precisely because the grants people most want to make broadly - security auditor, logging writer, network viewer - are environment-scoped rather than team-scoped. Projects can be moved between folders later, and moving one silently changes its inherited policy, so a move is an access change and deserves the same review.
Why the project is the fundamental unit
The project is not just a convenient policy scope. It is simultaneously the boundary for four other things, and that coincidence is what makes it the right unit of isolation.
APIs are enabled per project, so a project that has never enabled the Cloud SQL Admin API cannot be made to run a Cloud SQL instance no matter what roles you hold in it. Quota is allocated per project, so a runaway batch job cannot exhaust another team's capacity. Billing attaches per project, so cost attribution is a property of the structure rather than a labelling discipline nobody keeps up. And deletion is per project - shutting one down takes a soft-delete grace period and then removes every resource inside it, which is the only cheap way to reclaim an environment completely.
Two identifiers cause recurring confusion. The project ID is the globally unique, human-chosen string that appears in URLs and gcloud commands, and it can never be changed after creation. The project number is the numeric identifier Google assigns, and it is what shows up in default service account emails, workload identity pool paths and many log entries. Automation should carry both, because some APIs accept only one of them and a mismatch produces a permission error that reads like a policy problem.
The practical consequence: prefer more projects over more roles. Splitting a workload into its own project gives you an isolation boundary that policy alone cannot express, and it costs nothing but the automation to create it.
Allow policies are bindings, not documents
An allow policy on GCP is a small object with a list of bindings and an etag. Each binding is a role, a list of members, and optionally a condition. There is no Effect, no Action array and no Resource field, because the effect is always allow and the resource is the object the policy is attached to.
bindings:
- members:
- group:data-eng@example.com
role: roles/bigquery.dataViewer
- members:
- serviceAccount:ingest@acme-prod.iam.gserviceaccount.com
role: roles/pubsub.subscriber
condition:
title: only-the-orders-subscription
expression: >-
resource.name ==
"projects/acme-prod/subscriptions/orders-main"
etag: BwYX9k2h2Rc=
version: 3The etag is the concurrency control, and ignoring it is the single most common way a team wipes out someone else's grant. get-iam-policy, edit, set-iam-policy is a read-modify-write; if the policy changed in between, the mismatched etag makes the write fail rather than clobber. The add-iam-policy-binding and remove-iam-policy-binding commands do that loop for you with retries and should be the default in scripts. Hand-rolled full-policy writes belong only in Terraform-style tooling that owns the whole policy, and mixing the two - Terraform managing the full policy while a human adds a binding in the console - produces a policy that silently reverts on the next apply.
The version field is not cosmetic. Conditional bindings require policy version 3, and a client that requests version 1 gets a policy with the conditional bindings stripped out. Write that truncated view back and you have deleted every condition on the resource without any error being raised.
Basic, predefined, custom - and why basic is an anti-pattern
A role is a named bundle of permissions, and permissions are always service.resource.verb strings defined by Google - storage.objects.get, compute.instances.start. You never author a permission; you only choose how to bundle them.
Basic roles are the three that predate IAM: roles/viewer, roles/editor and roles/owner. Their defining property is that they are defined by exclusion rather than enumeration - Editor is roughly everything that mutates state across every service. That includes services that did not exist when the grant was made. Someone given Editor in 2021 acquired permissions on every product launched since, without a policy change, without a review, and without anything appearing in an audit diff. Owner additionally carries the ability to rewrite the project's own IAM policy, so it is not a strong role, it is an unbounded one.
Predefined roles are Google-maintained bundles scoped to one service and one job - roles/storage.objectViewer, roles/bigquery.jobUser, roles/logging.logWriter. They also change over time, but within the service's own surface and with the change published against the role. These should be the overwhelming majority of your bindings.
Custom roles exist for the cases where no predefined role fits and the gap actually matters. They can be created at the project or the organization level - not at a folder - and they carry a launch stage, so a role can be marked ALPHA or BETA while you are still shaping it and DISABLED to switch it off without deleting bindings. The costs are real: not every permission is available in custom roles, and nothing updates them when a service adds a permission its predefined roles pick up automatically. A custom role is a maintenance commitment, so create one only when a specific predefined role is demonstrably too broad for a specific principal.
One detail that catches people: basic roles cannot be used in a conditional binding. If you were planning to grant Editor with a time condition, that is not a supported policy - which is a reasonable hint about what Google thinks of the combination.
Principals - and the service account's dual nature
Members are written with a type prefix, and the prefix is what determines how the identity is authenticated and revoked: user: for a Google account, group: for a Google group, serviceAccount: for a service account, domain: for everyone in a Cloud Identity domain, and principal: or principalSet: for federated identities. Two special members, allUsers and allAuthenticatedUsers, mean the public internet and any Google account on earth respectively - the second is not meaningfully narrower than the first for security purposes.
Grant to groups, essentially always. A group membership change is one edit handled by whatever provisions your directory; a direct user: binding is a grant that has to be found and removed at every node in the hierarchy where someone might have made it.
The service account is where the model gets genuinely confusing, and it is worth stating plainly. A service account is both an identity and a resource. As an identity, it appears as a member in bindings on other resources: serviceAccount:ingest@acme-prod.iam.gserviceaccount.com holds roles/pubsub.subscriber on a topic. As a resource, it has its own allow policy, whose bindings say who is permitted to use it - to impersonate it, mint tokens for it, or attach it to a workload.
These are two completely separate permission surfaces on the same object, and reviewing one tells you nothing about the other. A service account with narrowly scoped roles is still a hole if half the engineering organization holds roles/iam.serviceAccountTokenCreator on it, because they can all become it. Conversely, a locked-down service account policy is irrelevant if the account itself was granted Editor at the folder. Any audit that does not walk both directions - what can this account do, and who can become this account - is incomplete.
Impersonation and short-lived credentials instead of key files
A service account key is a JSON file containing a private key with no expiry. It authenticates whoever holds it, it does not identify the human who copied it, and it survives that human leaving. Treat every exported key as a credential that will eventually appear in a repository, a CI variable, a laptop backup, or a screenshot, because in aggregate they all do.
The replacement is impersonation. Rather than holding the account's key, a principal holds a role on the service account and asks the IAM Credentials API to mint a short-lived token on demand. Two roles do the work and they are not interchangeable. roles/iam.serviceAccountTokenCreator grants the token-minting calls - generateAccessToken, generateIdToken, signBlob, signJwt - and is what a human or a CI job needs to act as the account directly. roles/iam.serviceAccountUser grants iam.serviceAccounts.actAs, which is the permission to attach the account to a resource you are creating: a VM, a Cloud Run revision, a Cloud Function, a Dataflow job.
The actAs permission is the classic Google Cloud privilege escalation path. Deploy permission plus actAs on a powerful service account equals that account's permissions, because you can deploy code that runs as it. Grant it on individual service accounts, never at the project level.
# Nothing stored: gcloud exchanges your own credential for a
# short-lived token belonging to the target account.
gcloud storage ls gs://acme-exports \
--impersonate-service-account=exporter@acme-prod.iam.gserviceaccount.com
# Same idea for the client libraries - the source credential is
# whatever ADC already resolved to.
gcloud auth application-default login \
--impersonate-service-account=exporter@acme-prod.iam.gserviceaccount.com
# Delegation chain, when the caller may only reach the target
# through an intermediate account.
gcloud ... --impersonate-service-account=final@p.iam.gserviceaccount.com \
--impersonate-service-account-delegates=hop@p.iam.gserviceaccount.comThe audit benefit is as large as the security benefit: the log entry for an impersonated call records both the service account and the principal that requested the token, so a shared automation identity stops being an anonymising layer. Enforce the pattern with the organization policy constraint constraints/iam.disableServiceAccountKeyCreation - a control that only works if it is set before someone needs a key at three in the morning, since exceptions granted under pressure become permanent.
Workloads running on Google Cloud need neither keys nor explicit impersonation: attach a service account to the VM, Cloud Run service or GKE pod and Application Default Credentials picks up a token from the metadata server. For the GKE-specific binding of a Kubernetes service account to a Google identity, see the GKE architecture article.
Workload Identity Federation for workloads outside Google Cloud
Federation solves the case the metadata server cannot: code running on AWS, Azure, a CI platform, or your own datacentre that needs to call Google Cloud APIs. The idea is to trust the external platform's own identity token instead of issuing a Google credential that has to be stored somewhere.
Two objects define the trust. A workload identity pool is a namespace for external identities within a project. A provider inside that pool configures one external issuer - an AWS account, or any OIDC or SAML identity provider - and carries the two settings that actually determine security. The attribute mapping translates claims from the incoming token into Google attributes: google.subject is mandatory and becomes the identity's name, and additional attribute.* values can be derived from claims with CEL expressions. The attribute condition is a CEL predicate that must evaluate true or the exchange is refused.
gcloud iam workload-identity-pools providers create-oidc github \
--location=global --workload-identity-pool=ci-pool \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,\
attribute.repository=assertion.repository,\
attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository_owner == 'acme'"
# Grant to a specific repository, not to the whole pool.
gcloud iam service-accounts add-iam-policy-binding \
deployer@acme-prod.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/482915037461\
/locations/global/workloadIdentityPools/ci-pool\
/attribute.repository/acme/payments"Read the member string carefully, because it is where federation is usually broken. principal:// names one federated identity by its mapped subject; principalSet:// names every identity sharing an attribute value. A binding to .../workloadIdentityPools/ci-pool/* grants access to every identity the provider will ever mint - on a public CI platform, that is every repository on the internet. Constrain to an attribute you control, and use the attribute condition as the second gate so that an unexpected issuer claim fails before any policy is consulted.
The exchange itself runs through the Security Token Service: the external token goes to sts.googleapis.com, which validates the signature against the issuer's JWKS and the attribute condition, and returns a federated access token. From there you have two options. The older pattern exchanges again for a service account token via impersonation, which is what roles/iam.workloadIdentityUser above enables. The newer pattern grants IAM roles to the principalSet directly on the target resources, removing the intermediary service account entirely - fewer objects, and no account sitting around whose keys someone could later export. Use direct access unless you need a feature that still requires a service account. Either way, gcloud iam workload-identity-pools create-cred-config writes a credential configuration file that the client libraries understand, and that file contains no secret - it is a pointer to where the local token can be read.
IAM Conditions - CEL, request context, and tags
A conditional binding grants the role only when a CEL expression evaluates to true. The expression is a predicate over a small attribute set, not a general programming environment, and the available attributes are the limiting factor rather than the language.
The practically useful ones are request.time for temporal restrictions, resource.name, resource.type and resource.service for narrowing a broad role to specific objects, and the tag functions for attribute-based access.
# Grant expires - the single most valuable condition there is.
request.time < timestamp("2026-10-01T00:00:00Z")
# Working hours in a named zone, not UTC.
request.time.getHours("Europe/London") >= 8 &&
request.time.getHours("Europe/London") < 19
# Narrow a bucket-wide role to one prefix.
resource.type == "storage.googleapis.com/Object" &&
resource.name.startsWith("projects/_/buckets/acme-exports/objects/public/")
# Attribute-based: only resources carrying the prod tag.
resource.matchTag("482915037461/env", "prod")Tags are not labels
This distinction is worth being pedantic about because the words are almost interchangeable in English and not at all in the platform. Labels are free-form key/value pairs on a resource, meant for billing breakdown and filtering; anyone who can edit the resource can edit its labels, and IAM conditions cannot read them. Tags are first-class resources created at the organization or project level, with their own IAM controlling who may create a tag value and who may attach it to a resource, and they inherit down the hierarchy. Only tags can appear in IAM conditions - and that is precisely because their integrity is enforceable. If attaching a tag were as easy as setting a label, tag-based access control would be self-service privilege escalation.
Where conditions stop being useful
Conditions are enforced by the service handling the request, so support is per-service and uneven: some resource types accept resource.name conditions, some accept only date and time, and some accept none. Verify against the real API before designing a control around one. Beyond that, three limits recur. Basic roles cannot carry conditions at all. Conditions are additive filters on a grant, so they narrow one binding and can never override a broader unconditional binding inherited from a folder. And a condition on a role that includes list or get-IAM-policy permissions frequently produces confusing partial results in the console rather than a clean denial, because the UI issues many calls and only some of them match. Expect to explain that one repeatedly.
Deny policies, Organization Policy, and VPC Service Controls are three things
These get conflated constantly, including in internal design documents. They are separate mechanisms with separate APIs, separate failure modes, and different questions they answer.
IAM deny policies answer may this principal use this permission. A deny policy attaches to an organization, folder or project and contains deny rules, each naming denied principals, denied permissions, optional exception principals, and an optional condition. Denial is evaluated before allow policies, so a matching deny rule ends the request regardless of any role granted anywhere in the hierarchy. This is the only mechanism that genuinely subtracts from inherited allow policies, which makes it the right tool for statements like nobody outside the security group may delete log buckets, even project owners. The caveat to check first: deny rules do not support every permission, so confirm the specific permission is supported before the control ends up in a compliance document.
Organization Policy answers a different question entirely: what configuration may exist here. It has nothing to do with principals. Constraints are boolean or list-valued and are evaluated when a resource is created or modified - constraints/iam.disableServiceAccountKeyCreation, constraints/storage.publicAccessPrevention, constraints/compute.vmExternalIpAccess, constraints/iam.allowedPolicyMemberDomains to stop external accounts being added to any policy in the org. Unlike allow policies, an org policy at a child node can override the inherited one, so the inheritance semantics you learned for IAM do not transfer. Custom constraints extend the set with your own CEL over supported resource types.
VPC Service Controls answers a third question: may data cross this boundary. A perimeter around a set of projects blocks API access to services inside it from outside, even for a caller holding every role, which is what makes it the mitigation for credential theft and data exfiltration rather than for over-privilege. See the VPC Service Controls article. The mental shorthand worth keeping: deny policies constrain who, organization policy constrains what may be built, service perimeters constrain where data may go. A design that reaches for the wrong one usually ends up with an unenforceable control that reviews well.
Policy Intelligence - the practical path to least privilege
Nobody derives a correct role set by reading documentation. Google ships four tools for the derivation, and they answer different questions.
The IAM recommender compares granted permissions against permissions actually exercised over a trailing window of roughly ninety days and proposes a narrower replacement, usually a smaller predefined role or a custom role. Its output is only as good as its window, so it will confidently recommend away the permissions a quarterly job or a failover runbook needs. Treat every recommendation as a hypothesis, and hold back anything the window plausibly missed.
Policy Troubleshooter answers the incident question: for this principal, this permission, and this specific resource, is access allowed, and which binding at which node in the hierarchy decided it. This is the tool that ends the argument about where a grant came from, and it should be the first thing anyone reaches for on a 403 rather than the last.
Policy Analyzer, in Cloud Asset Inventory, runs the query in the other direction and across scale: who has this permission anywhere under this organization, or what can this principal reach. It is the basis of any real access review, because it resolves group membership and inheritance instead of showing you one node's bindings.
Policy Simulator replays recent real access against a proposed policy and reports which of those calls would now be denied. This is the step that makes a tightening non-breaking, and it is the step teams skip. The working loop is: recommender proposes, analyzer confirms nobody else depends on the grant, simulator predicts breakage, then apply.
gcloud asset analyze-iam-policy \
--organization=482915037461 \
--identity="user:alice@example.com" \
--show-access-control-lists
gcloud policy-intelligence troubleshoot-policy iam \
--principal-email=ingest@acme-prod.iam.gserviceaccount.com \
--permission=storage.objects.get \
--resource=//storage.googleapis.com/projects/_/buckets/acme-exportsPropagation delay and eventual consistency
Policy changes are not transactional across the platform. A write to an allow policy returns success once it is durable, but enforcement points cache their view and converge afterwards - typically within seconds, occasionally in minutes, and not simultaneously across regions or services. For that interval the same principal can be allowed by one code path and denied by another.
Two operational rules follow. First, never gate an automated step on an immediately preceding grant: a pipeline that creates a service account, binds a role and calls the API in the next line will fail intermittently, and the failure looks exactly like a missing permission. Add a bounded retry with backoff around the first authorised call rather than a fixed sleep. Terraform users see this as the recurring race between google_project_iam_member and whatever resource depends on it.
Second, and more seriously, revocation is subject to the same delay. Removing a binding does not immediately terminate access, and any access token already issued remains valid until it expires on its own - revoking the role does not revoke the token. For an actual compromise, removing the binding is the beginning: also disable or delete the service account, delete any keys, and where the blast radius justifies it, use a deny policy, which is evaluated first and is the fastest available brake.
The classic failure modes
Default service accounts carrying Editor. Historically, enabling Compute Engine or App Engine in a project created a default service account and automatically granted it the Editor basic role at the project. Every VM launched without an explicit service account then ran as a project-wide editor, which turns any application-level remote code execution into full project compromise via the metadata server. Set constraints/iam.automaticIamGrantsForDefaultServiceAccounts at the organization so new projects do not inherit the problem, and audit existing projects for the numeric ...-compute@developer.gserviceaccount.com member holding roles/editor. Give every workload its own service account with its own roles - they are free, and they are the only way blast radius stays proportional.
Key files in repositories. The exported JSON key is still the most common Google Cloud credential leak, and it is the one with no expiry and no attribution. Disable key creation by constraint, delete the keys that already exist after migrating their consumers to impersonation or federation, and alert on key-creation events in the audit log rather than trusting that the constraint was never excepted.
Over-broad folder-level grants. Granting at a folder is how you avoid repeating a binding thirty times; it is also how one ticket gives someone access to thirty projects including the two nobody remembered were in that folder. The rule of thumb that holds up: broad scope is acceptable for read and audit roles, and grants that mutate state or touch IAM belong at the project or below.
Groups nobody owns. Because grants should go to groups, group membership becomes the real access control, and it is administered in Cloud Identity rather than in Cloud Console. An access review that examines IAM bindings without expanding group membership reviews nothing.
Conditions that were never enforced. A condition on a service or resource type that does not support conditions does not fail loudly. Test the denial path with the real API, and confirm the binding really is version 3.
Google Cloud IAM is a tree plus a union: allow policies attach to nodes in the organization-folder-project-resource hierarchy and a principal holds the union of every binding along a resource's ancestry, additively, with no way to subtract using allow policies alone. Build on that with predefined roles rather than basic ones, one service account per workload reached by impersonation or Workload Identity Federation rather than by an exported key, and conditions bound to tags rather than labels. When you need to take something away, know which of the three subtractive mechanisms you are reaching for - deny policies constrain who, Organization Policy constrains what may be built, and VPC Service Controls constrain where data may go.