IaC GCP

Terraform Module: GCP Custom IAM Role — least-privilege roles as version-controlled code

Quick take — A reusable hashicorp/google ~> 5.0 module for google_project_iam_custom_role: define curated permission sets, validate role IDs, manage the launch stage, and bind the role to members — all least-privilege by default. 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_custom_role" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iam-custom-role?ref=v1.0.0"

  project_id  = "..."           # Project in which the custom role is created.
  role_id     = "..."           # Immutable role identifier (3–64 chars, `[a-zA-Z0-9_.]`,…
  title       = "..."           # Console-visible name (1–100 chars).
  permissions = ["...", "..."]  # Permissions granted (`service.resource.verb`); at least…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

GCP ships thousands of predefined IAM roles, but they are coarse. roles/editor alone grants well over 3,000 permissions — far more than any single workload or human should hold. A custom IAM role lets you assemble an exact list of permissions (for example compute.instances.start, compute.instances.stop, and nothing else) into a named role that you grant like any other. In Terraform that is the google_project_iam_custom_role resource: a project-scoped role identified by a role_id, carrying a permissions list and a stage (launch stage) such as GA or BETA.

Wrapping it in a module matters because a raw custom role is deceptively fiddly. The role_id has strict naming rules (3–64 chars, letters/digits/underscores/periods, and it is immutable — changing it forces replacement). Permissions must be grantable at the project level or the apply fails. Soft-deleted roles linger for ~7 days and block re-creating a role with the same ID. And a role definition is useless until it is actually bound to a principal. This module standardises the naming, validates inputs before they reach the API, and optionally wires up the role binding so a consuming team gets a working, granted least-privilege role from one block.

When to use it

Use a predefined role instead when one already matches your need closely — custom roles add lifecycle overhead (permission deprecation tracking, the 7-day soft-delete window). Use google_organization_iam_custom_role (a sibling resource, not covered here) when the role must be reusable across an entire org rather than a single project.

Module structure

terraform-module-gcp-iam-custom-role/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # Permission lists must be de-duplicated and sorted so that re-ordering the
  # input never produces a spurious diff against the API (which returns them sorted).
  permissions = sort(distinct(var.permissions))
}

resource "google_project_iam_custom_role" "this" {
  project = var.project_id

  role_id     = var.role_id
  title       = var.title
  description = var.description
  stage       = var.stage
  permissions = local.permissions
}

# Optional: bind the freshly-created role to one or more members.
# Authoritative for THIS role only (google_project_iam_binding), so the module
# fully owns the membership of the role it created.
resource "google_project_iam_binding" "members" {
  count = length(var.members) > 0 ? 1 : 0

  project = var.project_id
  role    = google_project_iam_custom_role.this.id
  members = var.members
}
# variables.tf

variable "project_id" {
  description = "ID of the project in which the custom role is created."
  type        = string
}

variable "role_id" {
  description = "Immutable, project-unique role identifier (the part after roles/). Changing it forces a new role."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9_.]{3,64}$", var.role_id))
    error_message = "role_id must be 3-64 characters of letters, digits, underscores or periods only."
  }

  validation {
    # GCP reserves IDs that start with these prefixes for predefined/Google roles.
    condition     = !can(regex("^(goog|google)", lower(var.role_id)))
    error_message = "role_id must not start with 'goog' or 'google' (reserved prefixes)."
  }
}

variable "title" {
  description = "Human-friendly name shown in the Cloud Console (max 100 chars)."
  type        = string

  validation {
    condition     = length(var.title) > 0 && length(var.title) <= 100
    error_message = "title must be between 1 and 100 characters."
  }
}

variable "description" {
  description = "Human-readable description of what the role is for (max 256 chars)."
  type        = string
  default     = "Managed by Terraform"

  validation {
    condition     = length(var.description) <= 256
    error_message = "description must be 256 characters or fewer."
  }
}

variable "permissions" {
  description = "List of IAM permissions (e.g. compute.instances.start) granted by this role. Must be grantable at the project level."
  type        = list(string)

  validation {
    condition     = length(var.permissions) > 0
    error_message = "A custom role must contain at least one permission."
  }

  validation {
    # Catch role names accidentally pasted in place of permissions.
    condition     = alltrue([for p in var.permissions : can(regex("^[a-z][a-zA-Z0-9]*\\.[a-zA-Z]+\\.[a-zA-Z]+$", p))])
    error_message = "Each permission must look like 'service.resource.verb' (e.g. storage.buckets.get), not a role name."
  }
}

variable "stage" {
  description = "Launch stage of the role: ALPHA, BETA, GA, DEPRECATED, DISABLED or EAP."
  type        = string
  default     = "GA"

  validation {
    condition     = contains(["ALPHA", "BETA", "GA", "DEPRECATED", "DISABLED", "EAP"], var.stage)
    error_message = "stage must be one of ALPHA, BETA, GA, DEPRECATED, DISABLED or EAP."
  }
}

variable "members" {
  description = "Optional principals to grant this role to (e.g. serviceAccount:svc@proj.iam.gserviceaccount.com). Empty list creates no binding."
  type        = list(string)
  default     = []

  validation {
    condition = alltrue([
      for m in var.members :
      can(regex("^(user|serviceAccount|group|domain|principal|principalSet):", m))
    ])
    error_message = "Each member must be prefixed with a valid principal type (user:, serviceAccount:, group:, domain:, principal:, principalSet:)."
  }
}
# outputs.tf

output "id" {
  description = "Fully-qualified role identifier: projects/{project}/roles/{role_id}. Use this when granting the role."
  value       = google_project_iam_custom_role.this.id
}

output "name" {
  description = "Resource name of the custom role as returned by the API."
  value       = google_project_iam_custom_role.this.name
}

output "role_id" {
  description = "The short, immutable role_id."
  value       = google_project_iam_custom_role.this.role_id
}

output "permissions" {
  description = "The sorted, de-duplicated permission set effectively granted by the role."
  value       = google_project_iam_custom_role.this.permissions
}

output "stage" {
  description = "Current launch stage of the role."
  value       = google_project_iam_custom_role.this.stage
}

output "members" {
  description = "Members bound to the role by this module (empty if no binding was created)."
  value       = length(var.members) > 0 ? google_project_iam_binding.members[0].members : []
}

How to use it

# A least-privilege "compute operator" role: start/stop VMs and read logs, nothing more.
module "custom_iam_role" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iam-custom-role?ref=v1.0.0"

  project_id  = "kloudvin-prod-app-7f3a"
  role_id     = "computeOperator"
  title       = "Compute Operator"
  description = "Start/stop Compute Engine VMs and read their logs. No create/delete."
  stage       = "GA"

  permissions = [
    "compute.instances.get",
    "compute.instances.list",
    "compute.instances.start",
    "compute.instances.stop",
    "compute.instances.reset",
    "logging.logEntries.list",
  ]

  # Grant it straight to the on-call automation service account.
  members = [
    "serviceAccount:oncall-runner@kloudvin-prod-app-7f3a.iam.gserviceaccount.com",
  ]
}

# Downstream: reuse the role's fully-qualified id to grant a second principal
# elsewhere, without restating the permission set.
resource "google_project_iam_member" "break_glass" {
  project = "kloudvin-prod-app-7f3a"
  role    = module.custom_iam_role.id
  member  = "group:sre-breakglass@kloudvin.in"
}

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_custom_role/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-custom-role?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  role_id = "..."
  title = "..."
  permissions = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/iam_custom_role && 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 Project in which the custom role is created.
role_id string Yes Immutable role identifier (3–64 chars, [a-zA-Z0-9_.], not goog*). Changing it replaces the role.
title string Yes Console-visible name (1–100 chars).
description string "Managed by Terraform" No Description of the role’s purpose (≤256 chars).
permissions list(string) Yes Permissions granted (service.resource.verb); at least one, must be project-grantable.
stage string "GA" No Launch stage: ALPHA, BETA, GA, DEPRECATED, DISABLED, or EAP.
members list(string) [] No Optional principals to bind the role to; empty creates no binding.

Outputs

Name Description
id Fully-qualified id projects/{project}/roles/{role_id} — use this to grant the role.
name API resource name of the custom role.
role_id The short, immutable role_id.
permissions Sorted, de-duplicated permission set the role grants.
stage Current launch stage of the role.
members Members bound by this module (empty if no binding was created).

Enterprise scenario

KloudVin’s platform team replaced a blanket roles/editor grant held by every CI service account with a curated cicdDeployer custom role published through this module and pinned at v1.0.0 across ~40 application projects. Each project’s pipeline imports the module with the same permission set (Cloud Run deploy, Artifact Registry push, read-only Secret Manager access) and binds it to that project’s deploy service account via members, so a permission change is reviewed once, tagged, and rolled out by bumping the ref. During a SOC 2 audit the security team pointed at the module’s Git history as the single source of truth for exactly which permissions every deployer principal has ever held.

Best practices

TerraformGCPCustom IAM RoleModuleIaC
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