Chapter 8: Identity & Access on GCP
Topic 50

IAM Bindings: member vs binding vs policy

IAMFootgun

This is the most expensive footgun in GCP Terraform, so it leads with the danger: google_project_iam_policy applied to hatch-app-prod while omitting the org-admin binding will revoke the team's access in the same apply that revokes yours — that is a lockout, and recovery needs someone with inherited access from above. The reason this happens is that for every resource with an IAM policy, the google provider exposes three resource types with nearly identical names and wildly different authority.

google_*_iam_member adds one member to one role and touches nothing else. google_*_iam_binding owns every member of one role and silently removes anyone it does not list. google_*_iam_policy owns the entire policy of the resource and can delete every other binding, including the one keeping you out of the lockout. Pick the wrong one and your apply quietly revokes access something else granted, with no error and no warning. The rest of this topic is making sure you reach for the right one — which is almost always the first.

The Lockout: Read This Before You Use _policy

Applying google_project_iam_policy sets the project's entire IAM policy to exactly what your config declares. Any binding you did not include — the org-admin grant, another team's service account, the binding your own user relies on — is deleted on that apply. If you omit the break-glass admin binding, the apply that locks out the team also locks out you, and the only recovery is an org-level admin whose access is inherited from a higher node and therefore survives. This is why ancestor break-glass access, from the previous topic, matters in practice.

google_*_iam_member — Additive and Safe

The _member form adds a single (role, member) pair and is authoritative for only that pair. Every other member on the same role, and every other role on the resource, is left untouched. Nothing it does not name is at risk. This is the default you reach for, and for almost every real grant it is the right and only tool you need.

The binding below grants roles/run.developer to the developers group on hatch-app-prod. If another config grants that same role to a service account, both grants coexist; this resource manages its own pair and nothing else. There is no lockout mode here — _member cannot remove access it did not create.

_member — additive, the safe default
resource "google_project_iam_member" "run_dev" {
  project = "hatch-app-prod"
  role    = "roles/run.developer"
  member  = "group:gcp-developers@hatch.io"  # manages ONLY this pair
}

google_*_iam_binding — Authoritative Per Role

The _binding form declares the complete member list for one role. On every apply, Terraform makes reality match that list exactly — so any member added out-of-band, or by another Terraform config, or by Google automation, is removed. It is useful only when you genuinely want to own a role's full membership and nothing else manages that role on that resource.

The configuration below owns the entire membership of roles/run.developer on hatch-app-prod. If a separate process grants that role to a CI service account tomorrow, the next apply of this resource silently deletes it — no error, just a quiet revocation and a broken deploy somewhere else. That is the behavior you are signing up for; use it only when you mean it.

_binding — owns the full member list of ONE role
resource "google_project_iam_binding" "run_devs" {
  project = "hatch-app-prod"
  role    = "roles/run.developer"
  members = [
    "group:gcp-developers@hatch.io",
  ]                                       # anyone NOT listed is removed on apply
}

google_*_iam_policy — Authoritative for Everything

The _policy form sets the resource's entire IAM policy from a google_iam_policy data source. It owns every role and every member. A binding you forgot to include is deleted on the next apply, because the data source is the policy — there is no "merge with what exists." This is the resource that locks people out of projects, and it should be reserved for a resource that exactly one config is the sole authority on.

The snippet below builds a complete policy and applies it. Notice it must include the org-admin binding explicitly — leave it out and the apply removes org-admins' direct grant along with everyone else not listed. The only reason a lockout here is recoverable at all is that gcp-org-admins@hatch.io also holds an inherited grant from the org node, which this project-level policy cannot touch.

_policy — owns the ENTIRE policy; omissions are deletions
data "google_iam_policy" "prod" {
  binding {
    role    = "roles/run.developer"
    members = ["group:gcp-developers@hatch.io"]
  }
  binding {
    role    = "roles/owner"
    members = ["group:gcp-org-admins@hatch.io"]  # omit this and you lock the team out
  }
}

resource "google_project_iam_policy" "prod" {
  project     = "hatch-app-prod"
  policy_data = data.google_iam_policy.prod.policy_data
}
Three resources, three blast radii — widening left to right
google_project_iam_member
Adds one (role, member) pair and removes nothing else; additive, the safe default.
google_project_iam_binding
Owns one role's full member list; anyone not listed is silently removed on apply.
google_project_iam_policy
Owns the resource's entire policy; an omitted binding is deleted, which can lock you out.

Conflict and Drift

The three forms fight each other when they overlap. A _member and a _binding for the same role on the same resource conflict on every plan — the _binding wants to remove the member the _member keeps adding back, so the plan flaps forever. A _policy fights both, because it owns everything. Mixing authoritative and additive resources against one node produces a permanent diff that never settles, applying changes on every run that the next run reverts.

Recovering from the lockout itself is the harder case. An apply of google_project_iam_policy on hatch-app-prod that omits gcp-org-admins@hatch.io removes the admins' direct access in the same apply that removed yours. Recovery needs the org-level admin who still has an inherited grant from organizations/123456789012 — which is exactly why break-glass access at an ancestor node is not optional. Without it, nobody can fix the project's policy from inside the project.

_member vs _binding vs _policy

google_*_iam_member — manages one (role, member) pair, additive, removes nothing else. The safe default for almost every grant.

google_*_iam_binding — owns the full member list of one role and silently removes members it does not name. Use only when you truly own that role's membership and nothing else manages it.

google_*_iam_policy — owns the entire policy of the resource and deletes any binding not declared. Authoritative, lockout-capable, reserved for a single config that is the sole authority on that resource — and always include a break-glass admin binding.

Common Mistakes
  • Using google_project_iam_binding for roles/run.developer when another team also grants that role to a service account out-of-band — your next apply silently removes their member and breaks their deploys, with no error.
  • Applying google_project_iam_policy on hatch-app-prod and forgetting to include the org-admin binding — you revoke every grant not in your config and lock the team out of the project.
  • Mixing google_storage_bucket_iam_member and google_storage_bucket_iam_binding on the same role on hatch-events-raw — the two resources thrash the membership back and forth on every plan.
  • Reaching for the authoritative _binding or _policy resources because the names sound "more correct," when the additive _member is what almost every real grant needs.
  • Granting to user:alice@hatch.io with any of these instead of group:gcp-developers@hatch.io — the canon grants to groups, and a per-user binding becomes orphaned drift the day she changes teams.
  • Using _policy on a project that other configs also write IAM to — they will fight, and whichever runs last wins, silently undoing the other.
Best Practices
  • Default to google_*_iam_member for essentially every grant — it is additive, has no lockout mode, and composes cleanly with other configs.
  • Reserve google_*_iam_binding for roles whose entire membership you intentionally own, and document that ownership so nobody grants the role elsewhere.
  • Treat google_*_iam_policy as a sharp tool used by exactly one authoritative config per resource, and always include a break-glass admin binding so an apply cannot lock everyone out.
  • Never mix authoritative and additive IAM resources against the same role on the same node — the plan will flap permanently.
  • Always grant to groups (group:...@hatch.io), never to individual users, so a person changing teams never leaves an orphaned binding.
Comparable models AWS IAM no clean analog — it attaches policy documents to principals rather than offering additive-vs-authoritative variants, so this trap is GCP-Terraform-specific Config Connector IAMPolicyMember vs IAMPolicy mirrors the same additive/authoritative split gcloud projects add-iam-policy-binding always additive, like _member

Knowledge Check

Which of the three resource types is additive — managing only the pair it names and removing nothing else?

  • google_*_iam_member
  • google_*_iam_binding
  • google_*_iam_policy
  • All three behave as additive aliases

You use google_project_iam_binding for roles/run.developer, and another team grants that same role to a service account out-of-band. What happens on your next apply?

  • Terraform removes the service account, because _binding owns the full member list and silently deletes anyone not in it
  • Terraform imports the out-of-band service account into its state and appends it to your declared member list automatically
  • The apply errors out with a conflict because two separate configs now manage the same role binding
  • Nothing changes, because _binding is additive like _member

How does google_project_iam_policy lock a team out of a project?

  • It replaces the project's entire policy with exactly what is declared, so any binding omitted — including the admin grant — is deleted on apply
  • It disables the project's IAM and Cloud Resource Manager APIs, which blocks every console session and gcloud call until an owner manually re-enables them
  • It only revokes access for the identity that ran the apply, leaving every other team member's binding untouched
  • It cannot lock anyone out; _policy only ever adds bindings

Why do a _member and a _binding for the same role on the same resource produce permanent drift?

  • The _binding removes the member each apply, the _member re-adds it, so the plan flaps every run and never settles
  • They merge into one binding, which Terraform reports as a spurious diff that is safe to ignore
  • The provider deletes both resources from state to resolve the conflict, then recreates only the first one declared
  • There is no drift; Terraform always lets the _member win cleanly

You got correct