Skip to content

About

Terraform module: terraform-google-service-account

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Google Cloud Service Account Terraform Module

Creates and manages a single Google Cloud IAM service account (google_service_account) — never a key. Targets hashicorp/google ~> 7.0, Terraform >= 1.12.0.

Terraform Google Provider Module Version Module Type Resources Posture


🧩 Overview

  • 🔑 Creates one google_service_account — the IAM identity GCP workloads and CI/CD pipelines assume to call Google Cloud APIs.
  • 🚫 Never creates a google_service_account_key — no downloadable key material is generated by this module, ever.
  • 🏷️ Exposes the identity in every consumable form (id, email, name, unique_id, member) so sibling IAM-binding and compute modules can wire it in without re-deriving any string.
  • 🧱 Standalone by design — a service account has no natural for_each-managed child collection, so this module stays a single keystone resource with no composite shape.

💡 Why it matters: a service account is the identity every other workload-facing module in this library eventually grants access to or runs as. Getting its creation right — and keeping key material out of the picture entirely — is a foundational control, not an implementation detail.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits

graph LR
 SA["terraform-google-service-account"]:::thisModule
 IAMB["terraform-google-project-iam-bindings"]:::keystoneSibling
 GKE["terraform-google-gke-cluster"]:::sibling
 CI["terraform-google-compute-instance"]:::sibling
 IT["terraform-google-instance-template"]:::sibling
 CR["terraform-google-cloud-run-service"]:::sibling
 PS["terraform-google-project-services"]:::external

 PS -- "enables iam.googleapis.com" --> SA
 SA -- "email consumed as member" --> IAMB
 SA -- "email / id consumed by service_account field (future)" --> GKE
 SA -- "email / id consumed by service_account field (future)" --> CI
 SA -- "email / id consumed by service_account field (future)" --> IT
 SA -- "email consumed by service_account.email (future)" --> CR

 classDef thisModule fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:1px;
 classDef keystoneSibling fill:#174EA6,color:#ffffff,stroke:#174EA6,stroke-width:1px;
 classDef sibling fill:#E8EAED,color:#202124,stroke:#9AA0A6,stroke-width:1px;
 classDef external fill:#E8EAED,color:#202124,stroke:#9AA0A6,stroke-width:1px,stroke-dasharray: 3 3;
Loading

terraform-google-project-services (external, dashed) must have already enabled iam.googleapis.com before this module applies. terraform-google-project-iam-bindings is this module's most direct consumer today, taking the member output straight into its own for_each binding map. The compute/GKE/Cloud Run siblings are documented future consumers of email/id (their service_account fields) — this library does not yet ship those modules wired to this one, but the identity contract is stable today.


🧬 What this builds

graph LR
 subgraph Inputs
 A["var.account_id"]
 B["var.display_name"]
 C["var.description"]
 D["var.disabled"]
 E["var.create_ignore_already_exists"]
 F["var.timeouts"]
 end

 R["google_service_account.this"]:::thisModule

 A --> R
 B --> R
 C --> R
 D --> R
 E --> R
 F --> R

 R --> O1["output: id"]
 R --> O2["output: email"]
 R --> O3["output: name"]
 R --> O4["output: unique_id"]
 R --> O5["output: member"]

 classDef thisModule fill:#4285F4,color:#ffffff,stroke:#174EA6,stroke-width:1px;
Loading

Resource inventory: exactly one resource, google_service_account.this. No nested/repeating configuration blocks (the live schema has none for this resource) and no child resources — no for_each, no composite shape.


✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/google provider ~> 7.0
Provider block None — the caller configures google (project, region/zone, auth)

Schema notes that bite (verified against hashicorp/google v7.39.0 via terraform-registry):

  • account_id is force-new — changing it destroys and recreates the service account. There is no in-place rename.
  • account_id must be 6–30 characters, start with a lowercase letter, and match [a-z]([-a-z0-9]*[a-z0-9]) (RFC1035-style) — a hard GCP API constraint, enforced here at plan time via validation {}.
  • disabled has no effect during creation — GCP always creates the service account enabled; disabling it requires a subsequent apply with disabled = true.
  • description must be ≤ 256 UTF-8 bytes; this module's validation {} checks character count as a plan-time proxy (Terraform has no native byte-length function) — non-ASCII text can hit the real byte limit before 256 characters.
  • This resource has no self_link and no labels argument — both are common on most GCP resources but absent from the IAM Service Account API. See Architecture Notes.
  • timeouts supports only create (default 5 minutes) — no update/delete timeout exists in the schema.

🔑 Required IAM Roles

  • roles/iam.serviceAccountAdmin on the target project — create, update, and delete service accounts. No broader project-level role is required by this module alone.

☁️ GCP Prerequisites

  • iam.googleapis.com enabled on the target project (via terraform-google-project-services, applied before this module).

📁 Module Structure

terraform-google-service-account/
├── providers.tf # required_providers (hashicorp/google ~> 7.0) + required_version — no provider {} block
├── variables.tf # account_id, display_name, description, disabled, create_ignore_already_exists, timeouts
├── main.tf # google_service_account.this — the sole resource
├── outputs.tf # id, email, name, unique_id, member
├── README.md # this file
├── SCOPE.md # lightweight cross-module contract
└── examples/ # runnable example matching the Quick Start below

⚙️ Quick Start

module "runtime_service_account" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "app-runtime"
  display_name = "Application runtime identity"
}

The caller's root module configures the google provider (project, region/zone, and authentication via ADC, Workload Identity Federation, or a service account key supplied out-of-band) — this module accepts none of those as variables.


🔌 Cross-Module Contract

Consumes

Input Type Source module
(none — foundational IAM module, no cross-module inputs)

Emits

Output Description Consumed by
id projects/{{project}}/serviceAccounts/{{email}} Any module needing the Terraform-internal reference
email Service account e-mail address IAM binding modules; compute/GKE/Cloud Run service_account fields (future)
name Fully-qualified service account name Diagnostic/reference use
unique_id System-assigned unique id IAM condition expressions, audit correlation
member serviceAccount:{email} identity string terraform-google-project-iam-bindings member field

This resource has no self_link — none is emitted.


📚 Example Library

1 · Minimal call
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id = "app-runtime"
}

💡 The empty-beyond-account_id call is the safe, complete path: no key is ever created, and every other field takes its documented default.

2 · Display name and description
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "billing-export"
  display_name = "Billing Export Job"
  description  = "Runs the nightly BigQuery billing export; owned by the FinOps team."
}

ℹ️ display_name and description can both be updated in place — neither is force-new.

3 · Creating a disabled service account
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id = "legacy-batch-job"
  disabled   = true
}

⚠️ Per the live provider docs, disabled has no effect during creation — GCP always creates the account enabled. Setting disabled = true on the initial apply disables it on the very next apply (Terraform re-applies the desired state), not atomically at creation. Do not assume the account is ever created in a disabled state on the first apply.

4 · Adopting an existing service account by email
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id                   = "shared-ci-runner"
  create_ignore_already_exists = true
}

⚠️ create_ignore_already_exists = true avoids a create-time conflict error if a service account with the same email already exists, but it does not import the account's full existing state into this module's Terraform state. Review the provider docs before relying on this for adoption.

5 · Custom create timeout
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id = "slow-org-policy-project"

  timeouts = {
    create = "10m"
  }
}

ℹ️ Only create is configurable — the live schema exposes no update/delete timeout for this resource.

6 · Identity for a GKE workload (future consumer)
module "gke_workload_sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "gke-orders-api"
  display_name = "GKE Orders API workload identity"
}

# terraform-google-gke-cluster (or a Kubernetes-side IAM binding) would consume:
# module.gke_workload_sa.email
# as the Workload Identity-bound service account — no key material involved.

💡 This is the intended pattern for GKE Workload Identity: the pod's Kubernetes service account is bound to this identity's email, never to a downloaded key.

7 · Identity for a Cloud Run service (future consumer)
module "cloud_run_sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "cloud-run-api"
  display_name = "Cloud Run API runtime identity"
}

# terraform-google-cloud-run-service would consume module.cloud_run_sa.email as its
# google_cloud_run_v2_service.template.service_account value.
8 · Identity for a CI/CD pipeline via Workload Identity Federation
module "cicd_sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "github-actions-deploy"
  display_name = "GitHub Actions deployment identity"
  description  = "Impersonated via Workload Identity Federation; no key material issued."
}

🔒 Per this module suite's authentication model, this is the standard for CI/CD: GitHub Actions impersonates this service account through Workload Identity Federation. This module creates only the identity — the WIF pool/provider binding is configured separately, outside this module's scope.

9 · Multiple service accounts via root-module for_each
locals {
  service_accounts = {
    "orders-api"    = "Orders API runtime identity"
    "inventory-api" = "Inventory API runtime identity"
  }
}

module "workload_sas" {
  source   = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"
  for_each = local.service_accounts

  account_id   = each.key
  display_name = each.value
}

ℹ️ This module itself has no internal for_each (it is standalone, single-keystone) — a caller needing several service accounts wraps the module call in a for_each at the root/composition level, as shown here.

10 · account_id at the minimum length boundary
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id = "sa-abc" # 6 characters — the minimum GCP allows
}

⚠️ account_id must be 6-30 characters. A value shorter than 6 characters fails the validation {} block at plan time, before any API call is made.

11 · account_id at the maximum length boundary
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id = "app-runtime-prod-us-east1-01" # 28 characters — within the 30-char limit
}
12 · Long description near the byte-limit boundary
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id  = "audit-log-exporter"
  description = "Exports audit logs from Cloud Logging to a long-term retention bucket for compliance review; owned by the Security Engineering team; rotated per the records retention schedule."
}

⚠️ Keep non-ASCII characters in mind: this module's validation {} checks character count (≤ 256), which is a proxy for GCP's actual 256-byte limit. Plain ASCII text (as above) has an identical character and byte count; accented or non-Latin text does not.

13 · Directly consuming `member` in a resource-scoped IAM binding
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id = "bucket-reader"
}

resource "google_storage_bucket_iam_member" "reader" {
  bucket = "my-existing-bucket"
  role   = "roles/storage.objectViewer"
  member = module.sa.member
}

💡 A caller can consume member directly against any google_*_iam_member resource without going through terraform-google-project-iam-bindings — the aggregation module is a convenience for project-scoped bindings, not the only valid consumption path.

14 · Referencing `email` for a Workload Identity Federation impersonation target
module "sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "wif-deploy-target"
  display_name = "WIF impersonation target for deploy pipeline"
}

# A separate WIF pool/provider module (not in this library's current catalog) would bind:
# principalSet://iam.googleapis.com/<pool>/attribute.repository/<org>/<repo>
# to module.sa.email via a google_service_account_iam_member with
# roles/iam.workloadIdentityUser — configured outside this module's scope.

🔒 This module deliberately stops at creating the identity. Binding external principals to it (via google_service_account_iam_member) is out of scope here — see SCOPE.md's "Out of scope / consumed by id" section.

15 · 🏗️ End-to-end composition
module "project_services" {
  source = "git::https://github.com/microsoftexpert/terraform-google-project-services.git?ref=v1.0.0"

  # Enables iam.googleapis.com (and any other APIs the composition needs) —
  # applied before every other module in this composition.
}

module "orders_api_sa" {
  source = "git::https://github.com/microsoftexpert/terraform-google-service-account.git?ref=v1.0.0"

  account_id   = "orders-api"
  display_name = "Orders API runtime identity"
  description  = "Runtime identity for the Orders API Cloud Run service."

  depends_on = [module.project_services]
}

module "orders_api_iam" {
  source = "git::https://github.com/microsoftexpert/terraform-google-project-iam-bindings.git?ref=v1.0.0"

  bindings = {
    "orders-api-pubsub-publisher" = {
      role   = "roles/pubsub.publisher"
      member = "serviceAccount:${module.orders_api_sa.email}"
    }
    "orders-api-secret-accessor" = {
      role   = "roles/secretmanager.secretAccessor"
      member = "serviceAccount:${module.orders_api_sa.email}"
    }
  }
}

💡 This wires terraform-google-project-services → terraform-google-service-account → terraform-google-project-iam-bindings in dependency order: APIs enabled first, then the identity created, then IAM roles granted additively via member = "serviceAccount:${module.orders_api_sa.email}" (equivalently, module.orders_api_sa.member — both produce the identical string).

⚠️ Because service account creation is eventually consistent, and IAM grants can take up to ~60 seconds to propagate, a resource created immediately after in the same apply that depends on this service account having its granted roles already in effect may need a second apply to succeed cleanly on first-time bring-up. See Troubleshooting.


📥 Inputs

Variable Type Required Default Notes
account_id string Yes — Force-new; 6-30 chars, RFC1035-style
display_name string No null Updatable in place
description string No null ≤ 256 UTF-8 bytes (character-count proxy enforced)
disabled bool No false No effect during creation
create_ignore_already_exists bool No false Skips create-time conflict, not a full import
timeouts object({ create = optional(string) }) No null Only create — schema has no update/delete timeout
Full variable schemas
variable "account_id" {
  type = string
  # 6-30 chars, ^[a-z][-a-z0-9]{4,28}[a-z0-9]$ enforced via validation {}
}

variable "display_name" {
  type    = string
  default = null
}

variable "description" {
  type    = string
  default = null
  # <= 256 characters enforced via validation {} (proxy for GCP's 256 UTF-8 byte limit)
}

variable "disabled" {
  type    = bool
  default = false
}

variable "create_ignore_already_exists" {
  type    = bool
  default = false
}

variable "timeouts" {
  type = object({
    create = optional(string)
  })
  default = null
}

No labels variable exists on this module — see Architecture Notes for why.


🧾 Outputs

Output Description
id projects/{{project}}/serviceAccounts/{{email}}
email Service account e-mail address
name Fully-qualified service account name
unique_id System-assigned unique id
member serviceAccount:{email} identity string

ℹ️ No self_link row: this resource does not export one. Unlike most GCP resources covered by this library, the IAM Service Account API has no self_link concept — id is the only Terraform-internal reference form.

None of these outputs are secret-bearing; no sensitive = true is applied to any of them.


🧠 Architecture Notes

  • account_id is force-new. Changing it destroys and recreates the service account. Per the live provider docs, recreating a service account does not restore any IAM roles it previously held — those must be reapplied (typically automatic if the same for_each key still grants them in terraform-google-project-iam-bindings, but any binding managed outside Terraform is lost for good).
  • Eventual consistency on creation. GCP's IAM Service Account API is eventually consistent. Granting IAM roles to, or otherwise referencing, a service account created in the same apply can transiently fail even when the Terraform graph correctly orders creation before the dependent resource. This is an operational characteristic of the API, not a defect in this module or its callers.
  • No labels variable — deliberate schema-driven omission. The live google_service_account schema (verified against the live provider schema, hashicorp/google v7.39.0) has no labels argument. GCP's IAM Service Account API simply does not support resource labels, unlike the large majority of GCP resources this library targets. Per this module suite's own post-module checklist, a module documents this as an exception rather than fabricating a variable with no effect on the underlying resource — adding a decorative labels input here would violate the house rule that main.tf is a total, honest renderer of what the resource actually accepts.
  • No self_link output — same reasoning. The resource simply does not export one.
  • timeouts models only create. The schema has no update/delete timeout for this resource; the variable intentionally does not offer fields the provider would silently ignore.

🧱 Design Principles

Concern Secure default Opt-out (explicit)
Service account key material This module never creates a google_service_account_key — no key resource, no variable that could enable one N/A — use Workload Identity Federation or short-lived, impersonated credentials instead
Service account enabled/disabled state disabled = false (enabled) by default Caller sets disabled = true explicitly (effective on the apply after creation)
IAM role grants Not managed by this module at all — grant roles additively via terraform-google-project-iam-bindings or a resource-scoped _iam_member, never an authoritative _iam_policy N/A — authoritative IAM policy is intentionally excluded from this library
Existing-resource adoption create_ignore_already_exists = false by default (fails loudly on a naming collision rather than silently attaching to an unknown existing account) Caller sets create_ignore_already_exists = true explicitly

🚀 Runbook

cd terraform-google-service-account
terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module source to ?ref=v1.0.0 — never a branch. This library is plan-only from an authoring session; a human applies from CI with valid Workload Identity Federation or ADC credentials.


🧪 Testing

terraform validate confirms internal type and reference consistency (e.g. account_id's validation {} block, correct resource/output wiring). terraform fmt -check confirms canonical formatting. Neither can catch GCP API-level rejections — quota limits, org-policy constraints (e.g. constraints/iam.disableServiceAccountKeyCreation, which this module's own design already respects by never creating a key), or the eventual-consistency behavior noted in Architecture Notes. Only a real terraform plan/apply against a live project, with valid credentials, exercises those paths — that step belongs to the consuming CI pipeline, not this authoring session.


💬 Example Output

$ terraform output

email = "orders-api@casey-prod-workloads.iam.gserviceaccount.com"
id = "projects/casey-prod-workloads/serviceAccounts/orders-api@casey-prod-workloads.iam.gserviceaccount.com"
member = "serviceAccount:orders-api@casey-prod-workloads.iam.gserviceaccount.com"
name = "projects/casey-prod-workloads/serviceAccounts/orders-api@casey-prod-workloads.iam.gserviceaccount.com"
unique_id = "114522374981726543210"

🔍 Troubleshooting

Symptom Cause Fix
plan fails with an account_id validation error Value is shorter than 6 characters, longer than 30, contains uppercase/underscore/invalid characters, or doesn't start with a lowercase letter Adjust account_id to match ^[a-z]([-a-z0-9]*[a-z0-9])$, 6-30 characters total
IAM binding apply immediately after creating this service account fails with "service account not found" or a permission-denied error Service account creation is eventually consistent; the identity isn't yet resolvable to the IAM API Re-run apply (usually resolves within seconds to low minutes); avoid tight coupling of creation and first IAM grant in a single automated pipeline step without a retry
Recreating this module (after an account_id change) leaves the workload without previously-granted IAM roles account_id is force-new; GCP does not carry forward IAM grants to a new service account resource, even with the same account_id Re-apply the IAM binding module (terraform-google-project-iam-bindings or equivalent) after the service account recreation completes
disabled = true on the initial apply doesn't appear to take effect The field has no effect during creation per the provider docs Run a second apply — the disable takes effect once the account already exists
create_ignore_already_exists = true didn't bring an existing account's full configuration into state This flag only avoids a create-time conflict; it does not perform a state import Use terraform import (or an import {} block) to bring the existing resource under management if full state parity is required
No labels or self_link in variables.tf/outputs.tf — looks incomplete compared to other modules Deliberate: the live google_service_account schema has neither Confirmed against the live provider schema; see Architecture Notes

🔗 Related Docs

About

Terraform module: terraform-google-service-account

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages