GCP Lesson 38 of 98

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

In a nutshell

Think of GCP IAM like the access-control system in a large office building. Allow policies are key cards: security hands them out, and they only ever add access. Give someone a card for the third floor, then later a card for the whole building, and they can now open every door — you can’t take the building away by issuing a narrower card afterwards. That is exactly why “just grant a smaller role” so often fails: a broader grant somewhere up the chain already said yes.

This lesson is about the other half of the system — the parts that let you actually constrain a powerful identity. Deny policies are “sealed door” signs that override every key card: the door won’t open for anyone, except the one fire marshal you explicitly list. IAM Conditions are smart cards that only work during business hours or only on certain floors. And impersonation replaces the permanent master key you’d otherwise hand a night-shift contractor with a temporary badge that stops working at dawn — and logs every time it’s issued.

Put those together and you get the goal every security team is chasing: nobody walks around with standing admin rights. Access is granted narrowly, expires on its own, is bounded by a rule even an admin can’t cross, and every use of the emergency path sets off an alarm. By the end you’ll be able to author each of those controls and prove they actually bind.

GCP IAM request decision: an impersonated caller flows through deny policies, the allow union, and IAM Conditions to reach allow or PERMISSION_DENIED

The diagram traces one request left to right: the caller impersonates a service account, then IAM checks deny policies first (an explicit deny wins outright), unions every inherited allow binding across org→folder→project, and requires the matching binding’s CEL condition to be true — miss any gate and the default is PERMISSION_DENIED.

Level: Advanced → Expert · Time: ~40 min (longer if you stand up the break-glass workflow in step 8)

Prerequisites & what you’ll be able to do. You’ll get the most from this if you’re comfortable with GCP’s resource hierarchy (org → folder → project) and the basics of allow policies, roles, and service accounts. If any of that is shaky, start with GCP IAM fundamentals: roles, service accounts, and policy and the IAM deep dive on roles, policies, conditions, and Recommender; this lesson picks up where allow-only IAM leaves off. After working through it you’ll be able to:

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'

Going deeper

Everything above is the working toolkit. This section is the layer underneath it — the internals, limits, and sharp edges that separate a policy that looks right from one that holds up under audit and at scale.

Deny policies are a versioned v3 resource with real limits. Deny policies live in the IAM v3 surface (iam.googleapis.com, the policies collection), entirely separate from the allow policy you read with getIamPolicy. Each carries an etag, so any programmatic update is a read-modify-write — fetch, mutate, write back with the etag, retry on conflict, exactly as you would for allow policies. There is a ceiling on how many deny rules you can stack on one attachment point (a few hundred; check the current quota before you design a per-team fan-out of rules), and each change is eventually consistent — IAM changes take up to roughly seven minutes to fully propagate, so a freshly attached deny won’t necessarily block the very next call. Never test a deny by firing the action immediately and declaring victory when it’s still allowed; give it time, or you’ll ship a rule you think is live and isn’t.

Principal identifiers in deny policies use the v2 string form. Allow bindings use the friendly user:, group:, serviceAccount: prefixes. Deny policies (and Workload Identity Federation) use the longer principal identifier form:

principalSet://goog/public:all                          # every principal
principalSet://goog/group/GROUP_EMAIL                    # a Google group
principal://goog/subject/USER_EMAIL                      # one specific user
principalSet://goog/cloudIdentityCustomerId/CUSTOMER_ID  # your whole Cloud Identity org

principalSet:// names a set of identities; principal:// names exactly one. Get this wrong and your exceptionPrincipals silently matches nobody, so the deny applies to the very admins you meant to spare — which is precisely how people lock themselves out of break-glass.

IAM Conditions run a deliberately small CEL. The condition language is Common Expression Language, but IAM exposes only a curated slice: no user-defined functions, a fixed set of attributes, and per-service population that isn’t uniform. Two tag functions are worth knowing precisely:

resource.matchTag("123456789012/environment", "prod")   # by namespaced key NAME
resource.matchTagId("tagKeys/456", "tagValues/789")      # by immutable numeric ID

matchTag reads nicely but breaks if someone renames the tag key or value; matchTagId binds to immutable numeric IDs and survives renames — reach for the ID form in anything long-lived or IaC-managed. Remember too that resource.type/resource.name aren’t populated for every service, == is required (never =), and there’s a cap on how many conditional bindings a single policy can hold, so conditions scale by tag, not by enumerating a thousand resource names.

Deny policies are not org-policy constraints — know which problem you have. This is the distinction people conflate most. A deny policy is an IAM authorization control: it governs actions — “which principal may call which permission” — and a violation surfaces as PERMISSION_DENIED from the IAM check. An organization policy constraint (the orgpolicy.googleapis.com service) is a configuration control: it governs what resources may exist or how they may be configurediam.disableServiceAccountKeyCreation, compute.vmExternalIpAccess, gcp.resourceLocations — enforced by each service at create/update time, not by IAM’s per-request authorization. The disableServiceAccountKeyCreation policy you enforced in step 6 is a constraint (it forbids a configuration: creating a key), whereas denying iam.serviceAccounts.getAccessToken would be a deny policy (it forbids an action: minting a token). Rule of thumb: govern what can exist with an org-policy constraint; govern what an identity may do with a deny policy. Custom org-policy constraints (CEL over resource fields) are the config-side cousin of IAM Conditions — same language, different target. The resource hierarchy and org-policy guardrails lesson goes deep on the constraint side.

tokenCreator and actAs are different escalations — don’t swap them. roles/iam.serviceAccountTokenCreator (permission iam.serviceAccounts.getAccessToken) lets the caller mint the SA’s tokens and directly become it. roles/iam.serviceAccountUser (permission iam.serviceAccounts.actAs) lets the caller deploy or attach a resource that will run as the SA — a Cloud Run service, a GCE VM, a Cloud Function. Both are privilege escalations granted on the target SA, but they are not interchangeable: a deploy pipeline that attaches an SA to a Cloud Run service needs actAs, not tokenCreator; a human who wants to run gcloud as the SA needs tokenCreator. Audit both — and when you want to lock impersonation down precisely, you can write a deny policy or a custom role directly on iam.serviceAccounts.getAccessToken.

Downscoped tokens narrow a token below the SA’s own IAM. For the highest-blast-radius workloads there’s one more squeeze: after impersonation hands you an access token, you can exchange it at the Security Token Service for a downscoped token bounded by a Credential Access Boundary — a set of rules (currently Cloud Storage, bucket-level, up to ten rules) that cap what that specific token can touch, regardless of what the SA is otherwise allowed. It’s how a broker service mints a token that reads exactly one bucket for one downstream job. Overkill for most estates, but the right tool when a single SA legitimately holds broad access and you need to hand out surgically narrow slices of it.

Impersonation is auditable and rate-limited. Every token mint is written to Admin Activity audit logs (always on, no cost) under iamcredentials.googleapis.com — that’s the trail step 5 of Verify reads. The IAM Credentials API also has request-rate quotas, so a workload that mints a fresh token per call instead of caching it for the token’s lifetime can throttle itself under load. Cache the token for its validity window; don’t re-mint per request.

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.

Practice challenges

Work these against a sandbox org or project where you can safely attach policies. Replace every placeholder (123456789012, project IDs, SA and group emails) with your own; nothing here should run against production as written. Each solution notes the one idea it proves.

1. (Beginner) See the access you actually have, not just one node. Find everywhere user:dana@example.com can delete storage buckets under organization 123456789012 — including access inherited from folders and granted through groups.

<details> <summary>Solution</summary>

gcloud asset analyze-iam-policy \
  --organization=123456789012 \
  --identity="user:dana@example.com" \
  --permissions="storage.googleapis.com/buckets.delete" \
  --expand-groups \
  --format=json

Why: per-node get-iam-policy shows only bindings at that one resource; Policy Analyzer walks the whole hierarchy and --expand-groups resolves memberships, so you see real effective access instead of a local slice. </details>

2. (Beginner) Grant an admin role that expires on its own. Give user:dana@example.com roles/compute.admin on my-prod-project until 2026-08-01T00:00:00Z, with no cleanup ticket.

<details> <summary>Solution</summary>

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

Why: a request.time condition makes the binding self-expire — the grant simply stops applying after the timestamp, which is the backbone of just-in-time elevation. </details>

3. (Intermediate) Block a destructive permission with a break-glass exception. Attach a deny policy to folder 456789012345 that stops everyone from deleting buckets, except the group storage-breakglass@example.com.

<details> <summary>Solution</summary>

# deny-bucket-delete.yaml
rules:
- denyRule:
    deniedPrincipals:
    - "principalSet://goog/public:all"
    exceptionPrincipals:
    - "principalSet://goog/group/storage-breakglass@example.com"
    deniedPermissions:
    - "storage.googleapis.com/buckets.delete"
gcloud iam policies create deny-bucket-delete \
  --attachment-point="cloudresourcemanager.googleapis.com/folders/456789012345" \
  --kind=denypolicies \
  --policy-file=deny-bucket-delete.yaml

Why: deny wins over any inherited allow and matches the permission across every role — but the exception only removes the group from the deny; those admins still need their own allow binding to actually delete. </details>

4. (Intermediate) Confine a role to tagged resources only. Let group:dev-team@example.com administer Compute instances, but only ones tagged environment=nonprod (tag key namespaced under org 123456789012).

<details> <summary>Solution</summary>

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,description=Instance admin limited to nonprod-tagged resources'

Why: a resource-attribute condition scopes the grant to matching resources instead of the whole project — and for anything long-lived, prefer matchTagId("tagKeys/…","tagValues/…") so a tag rename can’t silently widen access. </details>

5. (Advanced) Impersonate through a two-hop chain, with no key anywhere. A caller (group:platform-deployers@example.com) must act as sa-b@proj.iam.gserviceaccount.com, but policy says they may only reach it through sa-a. Set up the grants and mint a token.

<details> <summary>Solution</summary>

# 1) caller may impersonate sa-a
gcloud iam service-accounts add-iam-policy-binding \
  sa-a@proj.iam.gserviceaccount.com \
  --member="group:platform-deployers@example.com" \
  --role="roles/iam.serviceAccountTokenCreator"

# 2) sa-a may impersonate sa-b
gcloud iam service-accounts add-iam-policy-binding \
  sa-b@proj.iam.gserviceaccount.com \
  --member="serviceAccount:sa-a@proj.iam.gserviceaccount.com" \
  --role="roles/iam.serviceAccountTokenCreator"

# 3) mint a token as sa-b, declaring the intermediate hop
gcloud auth print-access-token \
  --impersonate-service-account=sa-b@proj.iam.gserviceaccount.com \
  --delegates=sa-a@proj.iam.gserviceaccount.com

Why: the chain is verified hop-by-hop with no transitive shortcut — each tokenCreator grant is a deliberate bridge — and nothing exports a key, so there is no long-lived credential to leak. </details>

6. (Advanced) Retire keys everywhere and prove a sensitive SA has none. Stop anyone creating or uploading user-managed SA keys across org 123456789012, then verify deploy-sa@my-prod-project.iam.gserviceaccount.com carries zero user-managed keys.

<details> <summary>Solution</summary>

gcloud resource-manager org-policies enable-enforce \
  iam.disableServiceAccountKeyCreation --organization=123456789012
gcloud resource-manager org-policies enable-enforce \
  iam.disableServiceAccountKeyUpload --organization=123456789012

# An EMPTY result here is the goal:
gcloud iam service-accounts keys list \
  --iam-account=deploy-sa@my-prod-project.iam.gserviceaccount.com \
  --managed-by=user

Why: the two org-policy constraints forbid the key configuration going forward (distinct from a deny policy, which would forbid an action), and --managed-by=user isolates the liability — Google-managed keys always exist and are fine; user-managed ones are what you’re hunting. </details>

Common beginner mistakes

Glossary

Checklist

gcpiamdeny-policiesservice-accountsleast-privilege
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments