Quick take — Wrap google_project_iam_member in a reusable Terraform module for additive, non-destructive least-privilege bindings on GCP projects — with member-type validation, conditional IAM, and clean outputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "google" {
project = "my-project"
region = "us-central1"
}
module "iam_member" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iam-member?ref=v1.0.0"
project_id = "..." # GCP project ID to grant the role on (not the numeric nu…
role = "..." # Role to grant, e.g. `roles/run.admin` or a custom `proj…
member_type = "..." # Principal type: `user`, `serviceAccount`, `group`, `dom…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
google_project_iam_member grants a single principal (member) a single role on a GCP project, additively. This is the distinguishing trait that makes it the safe default for day-to-day access management: it is non-authoritative. Terraform only manages the exact (project, role, member) triple you declare — it never reads, owns, or removes other members already attached to that role. Console-granted access, org-policy-injected bindings, and other modules’ grants all survive a terraform apply.
Contrast that with its dangerous siblings. google_project_iam_binding is authoritative for a role — it overwrites the entire member list for that role on every apply, silently revoking anyone it doesn’t know about. google_project_iam_policy is authoritative for the whole project — one mistake can lock every human and service account out of the project, including you. google_project_iam_member has neither footgun, which is exactly why it belongs in a thin, reusable module.
Wrapping it pays off because real bindings are repetitive and easy to get subtly wrong: the member string must carry the correct prefix (user:, serviceAccount:, group:, domain:), roles are long and typo-prone, and conditional (time- or resource-bound) access needs a precisely shaped condition block. The module centralizes member-prefix validation, makes IAM Conditions a first-class optional input, and emits a stable etag and a composite id so downstream resources can depend on the grant actually existing.
When to use it
- Granting a service account a role on a project — e.g. a CI/CD deployer SA needs
roles/run.adminon the runtime project, without touching whoever else holds that role. - Onboarding a Google Group to a project — give
group:platform-team@example.comroles/viewerso membership is managed in Workspace, not in Terraform. - Time-boxed or resource-scoped elevation via IAM Conditions — grant
roles/compute.adminonly until a date, or only for resources whose name starts withdev-. - Cross-project access — let a SA from project A act on project B by adding it as a member on project B.
- You must NOT clobber existing access — any project where humans grant access out-of-band (console, gcloud) and an authoritative
_binding/_policywould wipe them out.
Reach for google_project_iam_binding only when you genuinely want Terraform to be the sole authority for a role’s membership, and google_project_iam_policy essentially never outside a greenfield, fully-codified project.
Module structure
terraform-module-gcp-iam-member/
├── versions.tf # provider + Terraform version pins
├── main.tf # google_project_iam_member (+ optional condition)
├── variables.tf # project, role, member, condition, validations
└── outputs.tf # id, etag, role, member
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Build the fully-prefixed IAM member string from type + identity,
# so callers pass "user" + "alice@example.com" instead of a raw,
# easy-to-typo "user:alice@example.com".
member = (
var.member_type == "allUsers" || var.member_type == "allAuthenticatedUsers"
? var.member_type
: "${var.member_type}:${var.member_identity}"
)
}
resource "google_project_iam_member" "this" {
project = var.project_id
role = var.role
member = local.member
# IAM Condition for time-bound or resource-scoped grants.
# Omitted entirely when var.condition is null, producing an
# unconditional binding.
dynamic "condition" {
for_each = var.condition == null ? [] : [var.condition]
content {
title = condition.value.title
description = condition.value.description
expression = condition.value.expression
}
}
}
variables.tf
variable "project_id" {
description = "ID of the GCP project on which to grant the role (not the numeric project number)."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id))
error_message = "project_id must be a valid GCP project ID (6-30 chars, lowercase letters, digits and hyphens)."
}
}
variable "role" {
description = "IAM role to grant, e.g. 'roles/run.admin' or a custom role 'projects/<p>/roles/<id>'."
type = string
validation {
condition = can(regex("^(roles/|projects/|organizations/)", var.role))
error_message = "role must start with 'roles/', 'projects/' (custom project role) or 'organizations/' (custom org role)."
}
}
variable "member_type" {
description = "Principal type. One of: user, serviceAccount, group, domain, allUsers, allAuthenticatedUsers."
type = string
validation {
condition = contains(
["user", "serviceAccount", "group", "domain", "allUsers", "allAuthenticatedUsers"],
var.member_type
)
error_message = "member_type must be one of user, serviceAccount, group, domain, allUsers, allAuthenticatedUsers."
}
}
variable "member_identity" {
description = "The principal's identity (email or domain). Leave empty only when member_type is allUsers/allAuthenticatedUsers."
type = string
default = ""
validation {
condition = (
contains(["allUsers", "allAuthenticatedUsers"], var.member_type)
? var.member_identity == ""
: length(trimspace(var.member_identity)) > 0
)
error_message = "member_identity is required for user/serviceAccount/group/domain, and must be empty for allUsers/allAuthenticatedUsers."
}
}
variable "condition" {
description = "Optional IAM Condition for a time-bound or resource-scoped grant. Set to null for an unconditional binding."
type = object({
title = string
description = optional(string, "")
expression = string
})
default = null
}
outputs.tf
output "id" {
description = "Composite identifier of the binding: '<project> roles/... <member> [condition_title]'."
value = google_project_iam_member.this.id
}
output "etag" {
description = "Etag of the project's IAM policy after the binding was applied; useful for change detection."
value = google_project_iam_member.this.etag
}
output "project_id" {
description = "Project the role was granted on."
value = google_project_iam_member.this.project
}
output "role" {
description = "Role that was granted."
value = google_project_iam_member.this.role
}
output "member" {
description = "Fully-prefixed member string that was granted the role."
value = google_project_iam_member.this.member
}
How to use it
# Grant a CI/CD deployer service account permission to deploy Cloud Run,
# scoped (via IAM Condition) to only resources named with a "prod-" prefix.
module "iam_member" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iam-member?ref=v1.0.0"
project_id = "kloudvin-runtime-prod"
role = "roles/run.admin"
member_type = "serviceAccount"
member_identity = google_service_account.deployer.email
condition = {
title = "prod-cloud-run-only"
description = "Limit run.admin to resources prefixed prod-"
expression = "resource.name.startsWith(\"projects/kloudvin-runtime-prod/locations/asia-south1/services/prod-\")"
}
}
resource "google_service_account" "deployer" {
project = "kloudvin-runtime-prod"
account_id = "cicd-deployer"
display_name = "CI/CD Cloud Run deployer"
}
# Downstream: surface the granted binding's etag so a CI gate can assert
# the IAM change actually landed before kicking off a deployment.
output "deployer_run_admin_etag" {
value = module.iam_member.etag
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/iam_member/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iam-member?ref=v1.0.0"
}
inputs = {
project_id = "..."
role = "..."
member_type = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/iam_member && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
project_id |
string |
— | Yes | GCP project ID to grant the role on (not the numeric number). |
role |
string |
— | Yes | Role to grant, e.g. roles/run.admin or a custom projects/<p>/roles/<id>. |
member_type |
string |
— | Yes | Principal type: user, serviceAccount, group, domain, allUsers, or allAuthenticatedUsers. |
member_identity |
string |
"" |
Conditional | Email/domain of the principal; required unless member_type is allUsers/allAuthenticatedUsers. |
condition |
object({ title, description, expression }) |
null |
No | Optional IAM Condition for time-bound or resource-scoped access; null = unconditional. |
Outputs
| Name | Description |
|---|---|
id |
Composite identifier of the binding (<project> <role> <member> [condition_title]). |
etag |
Etag of the project IAM policy after applying the binding; useful for change detection / CI gating. |
project_id |
Project the role was granted on. |
role |
Role that was granted. |
member |
Fully-prefixed member string (e.g. serviceAccount:cicd-deployer@...) that received the role. |
Enterprise scenario
A platform team manages 80+ application projects under a single GCP organization. Each app team’s Google Group needs roles/viewer plus a deploy SA needing roles/run.admin on its own runtime project, but security mandates that ad-hoc access granted by SREs during incidents (via console) must never be silently revoked by Terraform. They invoke this module once per (group, project) and (SA, project) pair from a for_each map in their landing-zone repo. Because every binding is additive google_project_iam_member, nightly terraform apply runs reconcile codified access without ever stripping break-glass grants — and IAM Conditions cap the deploy SAs to prod--prefixed resources for blast-radius control.
Best practices
- Default to
_member, never_binding/_policyfor shared projects. Additive bindings can’t accidentally revoke console-granted or break-glass access; authoritative resources can lock you out of the entire project in one apply. - Grant to groups, not individuals. Use
member_type = "group"so joiners/leavers are handled in Workspace; reserveuser:for rare exceptions and treat each one as an auditable line in code review. - Prefer predefined roles, and scope with IAM Conditions instead of reaching for
roles/ownerorroles/editor. Aresource.name.startsWith(...)orrequest.time < timestamp(...)condition tightens least privilege without minting a custom role. - Never use
allUsers/allAuthenticatedUserson data or admin roles — those make a project’s resources publicly reachable. Gate them behind code review and keep them off anything beyond intentionally-public endpoints. - One binding per module instance, driven by
for_each. Keep the module single-purpose and fan out grants from a map in the caller; this keeps plans readable and lets you remove one grant without disturbing the rest. - Reference grants via
etag/idoutputs to order deployments after IAM lands, and remember IAM is eventually consistent — allow for short propagation delays rather than asserting access immediately after apply.