IaC GCP

Terraform Module: GCP IAM Member — additive, least-privilege project bindings

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

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 configlive/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 configlive/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

TerraformGCPIAM MemberModuleIaC
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

Keep Reading