GCP Identity

Advanced GCP IAM: Deny Policies, Conditional Bindings, and Impersonation Chains

Allow-only IAM gets you surprisingly far and then quietly betrays you. Someone inherits roles/editor three folders up, a forgotten grant at the project node survives every cleanup, and your beautifully scoped binding is irrelevant because a broader one already said yes. Allow policies are additive and non-revoking: nothing in an allow policy can take away a permission another allow policy granted. To actually constrain a powerful identity you need the other half of the model — deny policies, conditions, and credentials that expire on their own. This is the toolkit I reach for when “just don’t grant Editor” stops being a viable answer.

1. How IAM actually evaluates a request

Before authoring anything, internalize the evaluation order, because deny policies invert the intuition you built on allow-only IAM:

  1. Gather deny policies attached at the resource and every ancestor (project, folder, org). If any matching deny rule applies to the principal and permission — and no exception applies — the request is denied, full stop. Deny wins over allow, always.
  2. Gather allow policies (the classic getIamPolicy bindings) at the resource and all ancestors. The union is additive: a grant anywhere in the chain counts.
  3. If an allow binding grants the permission and its condition (if any) evaluates true, allow. Otherwise the default is deny.

Two consequences matter. First, inheritance is real and union-based for allow: you cannot “scope down” by granting narrowly at the project if a broad role sits at the folder. Deny policies are how you claw that back. Second, deny policies are evaluated on permissions (e.g. storage.googleapis.com/buckets.delete), not roles — so they cut across every role that happens to include that permission.

# See the allow policy at a single node (does NOT show inherited bindings)
gcloud projects get-iam-policy my-prod-project --format=json

# Walk effective access for a principal across the hierarchy
gcloud asset analyze-iam-policy \
  --organization=123456789012 \
  --identity="user:dana@example.com" \
  --format=json

get-iam-policy is local to one node, which is exactly why teams under-estimate inherited access. analyze-iam-policy (Policy Analyzer) is the tool that tells the truth across the chain — more on it in step 7.

2. Authoring deny policies

Deny policies are a separate resource from allow policies. They attach to an attachment point — an org, folder, or project encoded in a specific URL form — and contain rules with deniedPrincipals, optional exceptionPrincipals, the deniedPermissions, and an optional denialCondition.

Here is a policy that blocks everyone from deleting projects and from deleting buckets, except a narrow break-glass group:

# deny-destructive.yaml
rules:
- denyRule:
    deniedPrincipals:
    - "principalSet://goog/public:all"
    exceptionPrincipals:
    - "principalSet://goog/group/breakglass-admins@example.com"
    deniedPermissions:
    - "cloudresourcemanager.googleapis.com/projects.delete"
    - "storage.googleapis.com/buckets.delete"

Attach it to a folder so it inherits to every project beneath:

# Attachment point must be URL-encoded: cloudresourcemanager.googleapis.com%2Ffolders%2FFOLDER_ID
gcloud iam policies create deny-destructive \
  --attachment-point="cloudresourcemanager.googleapis.com/folders/456789012345" \
  --kind=denypolicies \
  --policy-file=deny-destructive.yaml

A few rules that bite if you ignore them:

To list and read deny policies at a node:

gcloud iam policies list \
  --attachment-point="cloudresourcemanager.googleapis.com/folders/456789012345" \
  --kind=denypolicies

3. IAM Conditions: bounding the allow side

Deny policies are blunt. IAM Conditions are the scalpel on the allow side: a CEL expression attached to a binding so the grant only applies when the expression is true. Conditions match three attribute families.

Resource attributes — restrict a role to specific resources or types:

gcloud projects add-iam-policy-binding my-prod-project \
  --member="group:storage-ops@example.com" \
  --role="roles/storage.admin" \
  --condition='expression=resource.name.startsWith("projects/_/buckets/prod-logs-"),title=only-prod-logs-buckets,description=Storage admin limited to prod-logs buckets'

Date/time attributes — time-bound a grant so it self-expires, which is the backbone of elevation workflows:

gcloud projects add-iam-policy-binding my-prod-project \
  --member="user:dana@example.com" \
  --role="roles/compute.admin" \
  --condition='expression=request.time < timestamp("2026-06-15T00:00:00Z"),title=temp-compute-admin,description=Expires 2026-06-15'

Request attributes — match on properties of the call itself, such as the API resource path being created (request.path) or, very commonly, a hierarchical tag on the resource:

# Grant only on resources carrying tag environment=nonprod
gcloud projects add-iam-policy-binding my-shared-project \
  --member="group:dev-team@example.com" \
  --role="roles/compute.instanceAdmin.v1" \
  --condition='expression=resource.matchTag("123456789012/environment", "nonprod"),title=nonprod-only'

Caveats that trip people up: conditions support a deliberately limited CEL surface (no arbitrary functions), they cannot use = (use ==), and not all roles support every attribute — basic roles (owner/editor/viewer) cannot be conditioned at all. The resource.type and resource.name attributes are also not populated for every service, so test the condition against the actual API before trusting it.

4. Service account impersonation and short-lived tokens

The single highest-leverage move in GCP IAM is to stop handing out long-lived credentials and start impersonating. Instead of a human or a CI job holding a key for a service account, they hold their own identity plus the right to mint a short-lived token for the SA on demand.

The enabling role is roles/iam.serviceAccountTokenCreator, granted on the target service account (the SA being impersonated), to the caller:

gcloud iam service-accounts add-iam-policy-binding \
  deploy-sa@my-prod-project.iam.gserviceaccount.com \
  --member="group:platform-deployers@example.com" \
  --role="roles/iam.serviceAccountTokenCreator"

Now any member of that group can run gcloud as the SA without ever touching a key:

# Per-command impersonation
gcloud storage ls --impersonate-service-account=deploy-sa@my-prod-project.iam.gserviceaccount.com

# Mint a raw OAuth2 access token (default lifetime 1h, capped configurably)
gcloud auth print-access-token \
  --impersonate-service-account=deploy-sa@my-prod-project.iam.gserviceaccount.com

Application code uses the same mechanism through the client libraries’ impersonated credentials, so no token handling leaks into your app. Three flavors of short-lived credential come out of the IAM Credentials API, and picking the right one matters:

Token lifetime defaults to one hour. You can extend access tokens up to 12 hours, but only after explicitly allowlisting the SA via the constraints/iam.allowServiceAccountCredentialLifetimeExtension org policy. Shorter is safer; reach for the extension only when a long-running job genuinely needs it.

5. Delegation chains and the tokenCreator boundary

serviceAccountTokenCreator is, by design, a privilege-escalation primitive: whoever holds it on an SA becomes that SA, inheriting every permission the SA has. Treat the grant as equivalent to granting the SA’s entire role set to the caller. That is the boundary to defend.

Delegation chains let you impersonate transitively: A is allowed to impersonate B, B is allowed to impersonate C, and a caller acting as A can reach C only if each hop is explicitly authorized. You declare the intermediate hops with --delegates:

# Caller -> sa-a -> sa-b -> sa-c
# Requires: caller has tokenCreator on sa-a,
#           sa-a has tokenCreator on sa-b,
#           sa-b has tokenCreator on sa-c
gcloud auth print-access-token \
  --impersonate-service-account=sa-c@proj.iam.gserviceaccount.com \
  --delegates=sa-a@proj.iam.gserviceaccount.com,sa-b@proj.iam.gserviceaccount.com

The chain is verified hop-by-hop; there is no transitive shortcut. Use chains sparingly — they make audit harder and are usually a sign that a trust boundary (e.g. cross-project, or human-to-automation) wants an explicit, intentional bridge SA rather than a sprawl of direct grants. Audit every tokenCreator grant the way you would audit roles/owner, because functionally it can be.

6. Replacing keys with impersonation and WIF

Exported service account keys are the worst standing credential in most GCP estates: long-lived, copyable, and frequently committed. Two patterns replace them, and you should use both:

Once those two cover your access paths, slam the door on keys with org policy so no one can recreate the problem:

# Block creation of user-managed SA keys org-wide
gcloud resource-manager org-policies enable-enforce \
  iam.disableServiceAccountKeyCreation \
  --organization=123456789012

# And block key *upload* too
gcloud resource-manager org-policies enable-enforce \
  iam.disableServiceAccountKeyUpload \
  --organization=123456789012

Apply these at the org node with carefully scoped folder/project exceptions for the rare legacy system that genuinely cannot federate yet — and put that exception on a deadline.

7. Finding the risky grants you already have

You cannot least-privilege what you cannot see. Two services do the seeing.

Policy Analyzer answers “who can do what, where.” Query it for dangerous permissions across the org:

# Which principals can delete buckets anywhere under the org?
gcloud asset analyze-iam-policy \
  --organization=123456789012 \
  --permissions="storage.googleapis.com/buckets.delete" \
  --expand-groups \
  --format=json

--expand-groups is the flag that matters: it resolves group memberships so you see actual humans, not just the group binding, which is where over-grants hide.

IAM Recommender uses 90 days of observed usage to recommend tightening roles down to what an identity actually exercised:

gcloud recommender recommendations list \
  --project=my-prod-project \
  --location=global \
  --recommender=google.iam.policy.Recommender \
  --format="table(content.overview.member, content.overview.removedRole)"

Feed Recommender output into your IaC review, not directly into apply — it is advisory and occasionally over-eager on infrequently-used-but-required permissions. Pair it with deny policies for permissions that should never be used regardless of observed history.

8. A least-privilege break-glass and elevation workflow

Put the pieces together into the workflow I deploy in regulated estates. The principle: nobody holds standing admin; elevation is short-lived, conditioned, audited, and bounded by a deny policy that even the elevated role cannot cross.

  1. Baseline deny at the org/folder: deny the truly catastrophic permissions (org policy edits, billing detach, KMS key destroy) for public:all, with an exceptionPrincipals list of one tightly-controlled break-glass group.
  2. Just-in-time elevation: day-to-day, engineers carry read-mostly roles. To act, they request a time-bound conditional binding (request.time < ...) granting the operational role for a few hours, ideally minted by an automated approval system that writes the binding and lets it expire on its own.
  3. Act via impersonation, never via a personal admin grant: the elevated identity impersonates a purpose-built SA so the action runs as a named, auditable principal with exactly-scoped permissions.
  4. Break-glass is logged and loud: every use of the exception group fires an alert and a ticket. The path exists; using it is an event.

A condition for a self-expiring elevation, the kind your automation writes:

gcloud projects add-iam-policy-binding my-prod-project \
  --member="user:dana@example.com" \
  --role="roles/container.admin" \
  --condition='expression=request.time < timestamp("2026-06-08T18:00:00Z"),title=jit-gke-admin-incident-4821,description=Incident 4821 elevation, expires 18:00 UTC'

Verify

Prove the controls actually bind before you trust them:

# 1) Confirm the deny policy exists and is attached
gcloud iam policies list \
  --attachment-point="cloudresourcemanager.googleapis.com/folders/456789012345" \
  --kind=denypolicies

# 2) Confirm impersonation works AND that the SA's scope is what you think
gcloud storage ls --impersonate-service-account=deploy-sa@my-prod-project.iam.gserviceaccount.com

# 3) Confirm the denied action is actually blocked for a normal user
#    (run as a non-exception principal -> expect PERMISSION_DENIED)
gcloud storage rm gs://prod-logs-bucket --recursive

# 4) Confirm no user-managed keys exist on a sensitive SA
gcloud iam service-accounts keys list \
  --iam-account=deploy-sa@my-prod-project.iam.gserviceaccount.com \
  --managed-by=user

# 5) Audit who actually minted tokens (impersonation shows in Admin Activity logs)
gcloud logging read \
  'protoPayload.serviceName="iamcredentials.googleapis.com" AND protoPayload.methodName:"GenerateAccessToken"' \
  --project=my-prod-project --limit=20 --format=json

In step 4, the --managed-by=user filter is the one that matters: Google-managed keys always exist and are fine; user-managed keys are the liability. An empty result there is the goal.

Enterprise scenario

A platform team running a multi-tenant analytics estate had a recurring incident: a shared automation SA with roles/bigquery.dataEditor at the project level could, by inheritance, write to every tenant’s dataset — and one buggy pipeline did exactly that, cross-contaminating two tenants’ tables. The constraint was hard: tenants were isolated by dataset, not by project, so project-level IAM could not separate them, and they could not re-architect into per-tenant projects on the incident’s timeline.

The fix combined three of the tools above. First, a hierarchical tag tenant=<id> on each dataset. Second, the broad project-level grant was deleted and replaced with conditional bindings keyed on the tag, so each tenant’s automation SA could only touch its own datasets. Third — the part that made auditors sign off — a deny policy ensured no automation principal could ever delete a dataset, with a single break-glass exception group.

# Per-tenant scoped grant: SA only writes to datasets tagged tenant=acme
gcloud projects add-iam-policy-binding analytics-prod \
  --member="serviceAccount:acme-pipeline@analytics-prod.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataEditor" \
  --condition='expression=resource.matchTag("123456789012/tenant", "acme"),title=acme-datasets-only,description=Tenant isolation for acme pipeline'

The pipelines themselves moved to impersonation — the WIF-federated CI identity impersonates the per-tenant SA — so the same buggy job today simply gets PERMISSION_DENIED the moment it reaches across a tenant boundary, instead of silently succeeding. Same outcome, no re-platforming, enforced by IAM rather than by hoping the code is correct.

Checklist

gcpiamdeny-policiesservice-accountsleast-privilege

Comments

Keep Reading