Managing GCP IAM Bindings with Pulumi Python
Google's IAM resources are the only part of the GCP provider that can end a working day with a support ticket to Google. Three resource families exist for every access grant — IAMPolicy, IAMBinding and IAMMember — they differ by one word in the class name, they accept nearly identical arguments, and they have wildly different blast radii. Pick the first by accident on a live project and the next pulumi up deletes every binding on that project including your own. This guide belongs to GCP provider configuration under Pulumi patterns and provider management, and it is written around one goal: never being locked out of your own project.
Context
Google Cloud IAM stores a single policy document per resource. That document is a list of bindings, each pairing one role with a set of members and an optional condition, plus an etag for optimistic concurrency. There is no API for "add one member" — the only write operation is setIamPolicy, which replaces the entire document. Everything else, including all three Pulumi resource families, is a read-modify-write loop built on top of that one call.
The three families differ purely in how much of the document they claim ownership of:
IAMPolicyis authoritative for the whole policy. It writes exactly the bindings you declared and deletes everything else. Applied to a project, that includes the roles held by you, by your CI service account, and by every Google-managed service agent.IAMBindingis authoritative for one role. The declared members become the complete member list for that role; anyone else holding it is removed. Bindings for other roles are untouched.IAMMemberis additive for one member in one role. It adds the pair if missing, removes only that pair on destroy, and never looks at anything else in the document.
The IAMPolicy lockout is not hypothetical and it is not slow. A typical sequence: someone wants to codify the project's access, exports the live policy with gcloud projects get-iam-policy, pastes a trimmed version into gcp.projects.IAMPolicy, and runs pulumi up. The trimmed version omits roles/owner for two colleagues, omits the Cloud Build service agent, and omits [email protected]. The apply succeeds — the caller had permission at the moment it ran — and every one of those principals loses access at once. The next command from anyone returns:
googleapi: Error 403: The caller does not have permission, forbidden
Permission 'resourcemanager.projects.setIamPolicy' denied on resource
'//cloudresourcemanager.googleapis.com/projects/acme-prod' (or it may not exist).
Nobody can grant the permission back, because granting requires the permission that was just removed. Recovery needs a principal above the project: someone with roles/resourcemanager.organizationAdmin or roles/resourcemanager.folderAdmin who can call setIamPolicy on the project from the parent node. If the project has no organisation — a standalone project created under a personal account — there is no principal above it, and the only route back is Google Cloud Support.
Note also what the resource does when removed. Destroying an IAMPolicy does not restore the pre-existing access; the provider writes the policy it recorded at creation time, which is a snapshot from before your program existed and may be years stale. There is no clean undo in either direction.
Prerequisites
pulumi-gcp>=7.0and Python 3.9+, with the program already authenticating as a principal that holdsroles/resourcemanager.projectIamAdminon the target project.- A second, human-held break-glass identity with
roles/ownerthat is not managed by this Pulumi program. This is the single most valuable prerequisite on the page. - The project attached to an organisation or folder, with a named human able to administer IAM at that level.
gcloudfor reading policies back, andjqif you plan to diff them.
# CLI: snapshot the current policy before writing any IAM code, and keep the file
gcloud projects get-iam-policy acme-prod --format=json > iam-backup-$(date +%F).json
gcloud projects get-iam-policy acme-prod --format='value(etag)'
Implementation
1. Choose the resource family deliberately
The decision is about ownership, not convenience. Ask whether your Pulumi program is the only writer of the thing you are about to declare. Almost nothing on a project satisfies that, because Google itself adds service agent bindings when APIs are enabled.
| Resource | Owns | Safe on a project? | Typical use |
|---|---|---|---|
gcp.projects.IAMPolicy |
the entire policy | Essentially never | A greenfield project created and owned entirely by one program |
gcp.projects.IAMBinding |
all members of one role | Only for a role nothing else grants | roles/logging.logWriter on a project where you own that role |
gcp.projects.IAMMember |
one (role, member) pair | Yes | Almost every project-level grant you will ever write |
The default answer is IAMMember. Reach for IAMBinding only when you can name every principal that should hold a role and you actively want extras removed — for example, keeping roles/iam.serviceAccountTokenCreator on a sensitive service account limited to exactly two callers. Reach for IAMPolicy when the resource was created by the same program in the same stack and nothing else writes to it.
# infra/iam_project.py — additive project grants, the only safe default
# CLI: pulumi preview --diff && pulumi up
from __future__ import annotations
from dataclasses import dataclass
import pulumi_gcp as gcp
PROJECT = "acme-prod"
@dataclass(frozen=True)
class Grant:
key: str
role: str
member: str
PROJECT_GRANTS: tuple[Grant, ...] = (
Grant("ci-deployer", "roles/run.developer", "serviceAccount:[email protected]"),
Grant("ci-logs", "roles/logging.logWriter", "serviceAccount:[email protected]"),
Grant("oncall-viewer", "roles/viewer", "group:[email protected]"),
)
for grant in PROJECT_GRANTS:
gcp.projects.IAMMember(
grant.key,
project=PROJECT,
role=grant.role,
member=grant.member,
)
# State implication: destroying this resource removes ONLY this role/member
# pair. Nothing else in the project policy is read or rewritten.
Using a group: member rather than a list of user: members is worth the extra minute. Group membership changes without a deploy, so joiners and leavers never require an infrastructure pull request, and the policy document stays short enough to read.
2. Grant at the resource, not at the project
roles/storage.objectViewer on a project grants read access to every object in every bucket, now and in the future. The same role on one bucket grants read access to that bucket. The resource-level form is nearly always what was meant, and every service that supports IAM exposes its own member resource for exactly this.
# infra/iam_resource.py — the same principal, scoped to individual resources
# CLI: pulumi up && gcloud storage buckets get-iam-policy gs://acme-reports
from __future__ import annotations
import pulumi
import pulumi_gcp as gcp
app_sa = gcp.serviceaccount.Account(
"reporting-app",
account_id="reporting-app",
display_name="Reporting service runtime identity",
)
app_member = pulumi.Output.concat("serviceAccount:", app_sa.email)
gcp.storage.BucketIAMMember(
"reports-reader",
bucket=reports_bucket.name,
role="roles/storage.objectViewer",
member=app_member,
)
gcp.bigquery.DatasetIamMember(
"warehouse-reader",
dataset_id=warehouse.dataset_id,
role="roles/bigquery.dataViewer",
member=app_member,
)
# Provider note: this grants a right ON the service account itself — the ability
# for the GKE workload identity principal to impersonate it. It is IAM on an IAM
# object, and it is the grant people forget.
gcp.serviceaccount.IAMMember(
"reporting-workload-identity",
service_account_id=app_sa.name,
role="roles/iam.workloadIdentityUser",
member="serviceAccount:acme-prod.svc.id.goog[reporting/reporting-sa]",
)
Two details in that block bite regularly. First, service_account_id wants the account's fully qualified resource name (app_sa.name, which renders as projects/acme-prod/serviceAccounts/…), not the email — passing the email produces a 404 from the IAM API. Second, the class names are not consistently cased across modules: it is BucketIAMMember and DatasetIamMember and SecretIamMember and ServiceIamMember. Guessing yields AttributeError: module 'pulumi_gcp.secretmanager' has no attribute 'SecretIAMMember', which is at least a fast failure.
3. Use IAMBinding where you genuinely own the role
There is a legitimate use for the authoritative-per-role form: making a sensitive role's membership a closed set that drift cannot widen. If someone grants themselves roles/iam.serviceAccountTokenCreator through the console, the next pulumi up takes it away again, which is the entire point.
# infra/iam_binding.py — an authoritative, drift-correcting member list for ONE role
# CLI: pulumi up --diff # read the diff: removals here are real removals
from __future__ import annotations
import pulumi_gcp as gcp
gcp.serviceaccount.IAMBinding(
"deploy-impersonators",
service_account_id=deploy_sa.name,
role="roles/iam.serviceAccountTokenCreator",
members=[
"serviceAccount:[email protected]",
"group:[email protected]",
],
# State implication: any principal holding this role on this service account
# and absent from `members` is REMOVED on the next update.
)
Never mix the two forms on the same role and resource. An IAMBinding and an IAMMember targeting the same role fight each other on every apply: the binding removes the member, the member re-adds it, and the pair produces a permanent non-empty diff that trains everyone to ignore previews.
4. Add a condition when the grant should be narrow or temporary
Conditions attach a CEL expression to a binding, letting you scope a role to a name prefix or an expiry date. They are supported on the IAMMember and IAMBinding forms through a condition argument.
# infra/iam_conditional.py — a prefix-scoped read grant and a time-boxed one
# CLI: pulumi up && gcloud projects get-iam-policy acme-prod --format=json | jq '.bindings[] | select(.condition)'
from __future__ import annotations
import pulumi_gcp as gcp
gcp.storage.BucketIAMMember(
"reports-2026-reader",
bucket=reports_bucket.name,
role="roles/storage.objectViewer",
member="group:[email protected]",
condition=gcp.storage.BucketIAMMemberConditionArgs(
title="only-2026-exports",
description="Read access limited to objects under the 2026/ prefix",
expression=(
'resource.name.startsWith('
'"projects/_/buckets/acme-reports/objects/2026/")'
),
),
)
gcp.projects.IAMMember(
"migration-temporary-editor",
project="acme-prod",
role="roles/editor",
member="user:[email protected]",
condition=gcp.projects.IAMMemberConditionArgs(
title="migration-window",
description="Expires automatically at the end of the migration",
expression='request.time < timestamp("2026-09-30T00:00:00Z")',
),
# Provider note: the condition is part of this resource's identity. Editing the
# expression replaces the binding rather than updating it in place.
)
Conditions have real limits worth knowing before you design around them. Not every service evaluates every attribute — resource.name conditions work for Cloud Storage objects but are ignored by services that do not surface a resource name to IAM, in which case the binding grants unconditionally. Test the expression against a real request before treating it as a control, and prefer a shorter-lived grant over a clever expression when both would work.
Verification
Read the effective policy back rather than trusting the apply output. The flattened form is the one to memorise, because it answers "what does this principal actually hold?" in a single command:
# CLI: every role held by one service account on the project
gcloud projects get-iam-policy acme-prod \
--flatten="bindings[].members" \
--format='table(bindings.role, bindings.condition.title)' \
--filter="bindings.members:serviceAccount:[email protected]"
# CLI: resource-level policies are separate documents and must be read separately
gcloud storage buckets get-iam-policy gs://acme-reports --format=json
gcloud secrets get-iam-policy db-password --project acme-prod --format=json
A project policy that looks correct does not mean a principal can do the thing — inheritance from a folder or organisation adds roles that get-iam-policy on the project will not show. For the whole picture, ask the policy troubleshooter about the specific permission:
# CLI: does this principal actually hold this permission, inheritance included?
gcloud policy-intelligence troubleshoot-policy iam \
--principal-email=[email protected] \
--permission=run.services.update \
--resource=//run.googleapis.com/projects/acme-prod/locations/europe-west1/services/api
Before any IAM change reaches production, diff the policy the program will write against the one that exists. A short preview job that dumps both to JSON and runs diff catches an accidental authoritative resource while it is still a diff rather than an incident.
Gotchas & Edge Cases
Concurrent writes to one policy document. Several IAM resources targeting the same project apply in parallel, each doing its own read-modify-write, and the loser gets Error 409: There were concurrent policy changes. Please retry the whole read-modify-write with exponential backoff. The provider retries, but under a large fan-out you can exhaust the retries; serialise with depends_on or lower the parallelism for that stack.
pulumi refresh cannot see what IAMMember does not own. An additive member resource only tracks its own pair, so a role granted by hand in the console produces no drift and no diff. That is the deliberate trade for safety, and it means Pulumi is not an inventory of who has access — gcloud is.
Deleted principals leave tombstones. Delete a service account that still appears in a policy and the member string becomes deleted:serviceAccount:[email protected]?uid=1234567890. The binding still exists, still counts against policy size limits, and will not match your declared member.
Propagation is not instantaneous. IAM changes usually take effect within seconds but are documented as taking up to seven minutes. A pipeline that grants a role and immediately uses it needs a retry, not a sleep.
Importing IAM resources needs the right composite ID. For a project member it is the three fields space-separated: pulumi import gcp:projects/iAMMember:IAMMember ci-logs "acme-prod roles/logging.logWriter serviceAccount:[email protected]". Get the order wrong and the import silently attaches to a binding you did not mean.
Primitive roles are not a starting point. roles/editor includes the ability to change most IAM on most resources, so granting it to a workload effectively grants that workload the power to grant itself more. If a service account needs roles/editor, the requirement is usually two or three predefined roles that nobody looked up.
Operational Notes
Keep IAM in its own Pulumi stack, or at minimum its own file, and require a second reviewer on it. The change that locks a team out is never labelled as risky in the pull request title; it is a two-line edit inside a hundred-line refactor. A dedicated file makes the diff impossible to miss and makes CODEOWNERS enforcement trivial.
Break-glass access must live outside the automation. At least one human owner, and ideally two in different time zones, should hold roles/owner granted manually and documented as out of scope for infrastructure code. Test it: log in as that identity once a quarter and confirm it can still call setIamPolicy. An untested recovery path is a belief, not a control.
Organisation policy constraints are the backstop that Pulumi cannot undo from inside the project. constraints/iam.allowedPolicyMemberDomains stops any binding to a principal outside your domains, which blocks the accidental allUsers grant discussed in deploying Cloud Run services with Pulumi Python at the moment the API call is made rather than at review time. constraints/iam.disableServiceAccountKeyCreation closes the other common exfiltration route.
Finally, review grants on a schedule, not on incident. Export every project policy to a bucket weekly, keep the history, and diff month over month. The interesting signal is rarely a single dangerous grant; it is the slow accumulation of thirty principals holding roles/editor because each one was urgent on the day it was added.
FAQ
What exactly happens if I apply gcp.projects.IAMPolicy to an existing project?
Every binding not present in your declaration is deleted in one API call, including your own roles and Google's service agent bindings. Services begin failing immediately — Cloud Run cannot pull images, Cloud Build cannot write logs — and you cannot fix it from inside the project. Recovery requires an organisation or folder administrator, or Google Cloud Support if the project has no parent.
Can I recover from a lockout without contacting support?
Only if a principal outside the project still has authority over it. An organisation admin can call setIamPolicy on the project from the parent node and restore your access; a folder admin can do the same for projects in that folder. With no organisation and no remaining owner, support is the only path, and it is measured in days.
When is IAMBinding the right choice rather than IAMMember?
When you want drift removed. If the complete list of principals holding a role is a decision you are making and enforcing — impersonation rights on a deploy identity, for example — IAMBinding reverts out-of-band grants on the next apply. If you are simply adding an entry to a shared resource, IAMMember is correct.
Why does my conditional binding grant more than the condition allows?
Most likely the service does not evaluate the attribute you used. Conditions on resource.name require the service to publish a resource name to IAM, and where it does not, the condition is ignored rather than failing closed. Verify with the policy troubleshooter against a real request before relying on the expression.
How do I grant a service account access to one bucket instead of the whole project?
Use gcp.storage.BucketIAMMember with the bucket name and the specific role — roles/storage.objectViewer for read, roles/storage.objectAdmin for write. Do not use gcp.projects.IAMMember with a storage role, because that grants the role on every bucket in the project including ones created later.
Do I need a separate resource for every role a principal holds?
Yes, one IAMMember per role/member pair. Building them from a typed list in a loop, as in step one, keeps that readable and gives each resource a stable Pulumi name so a later reordering does not replace unrelated grants.
Related
- Deploying Cloud Run Services with Pulumi Python — where an invoker grant decides whether a service is public, and how it fails.
- Creating and Securing GCS Buckets with Pulumi (Python) — uniform bucket-level access, which makes bucket IAM the only access path.
- GCP Provider Configuration — the credential and project scoping every grant here is applied through.