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
- You are replacing a broad
roles/editor/roles/ownergrant with a tight, audited permission set for a service account or workload identity. - A platform/landing-zone team needs to publish a catalogue of standard custom roles (e.g.
deployer,incidentResponder,costViewer) consistently across many projects. - You want permission changes to go through code review and CI rather than
gcloud iam roles createrun by hand. - You need the role definition and its binding to a member to live together so drift between “the role exists” and “someone can use it” is impossible.
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 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_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
- Stay least-privilege. List only the permissions a workload actually exercises; run
gcloud beta iam roles create --dry-runstyle reviews or check Policy Analyzer recommendations before widening a role. Avoid wildcards — there are none here for a reason. - Treat
role_idas permanent. It is immutable; a rename forces a destroy/create, and the old ID stays soft-deleted for ~7 days, blocking reuse. Pick a stablecamelCasenaming convention (computeOperator,costViewer) up front. - Track permission deprecation. GCP deprecates and removes permissions over time; a removed permission silently drops from the role. Periodically diff
var.permissionsagainstgcloud iam list-testable-permissionsfor the resource and keepstagehonest (GAonly when stable). - Bind authoritatively, scope tightly. This module uses
google_project_iam_bindingfor the role it owns, so it is the single source of truth for that role’s membership — never also manage the same role with a straygoogle_project_iam_memberoutside the module, or the two will fight on every apply. - Pin the module ref and the provider. Consume via an immutable tag (
?ref=v1.0.0) and keephashicorp/google ~> 5.0so a provider bump never quietly changes how permissions or stages are serialised. - Prefer project-scoped custom roles over org-scoped unless truly shared. Project roles keep blast radius small and let each team own its catalogue; promote to an organization custom role only when the exact same role is genuinely needed fleet-wide.