WIF Terraform Import Configuration
The WIF pool, provider, and service account were originally created out-of-band (via the console / gcloud) before Terraform owned them. To bring them under Terraform without recreating them, we used Terraform 1.5+ import blocks, which let you declare imports declaratively in code and have them executed on the next terraform apply. The import.tf file used for this project is below:
###############################################################################
# Import existing GCP resources into Terraform state.
#
# These `import` blocks are one-shot: after a successful `terraform apply`
# brings the resources into state, delete this file (or comment everything
# out) so future plans don’t re-run the imports.
###############################################################################
import {
to = google_iam_workload_identity_pool.github
id = "projects/solution-comunity/locations/global/workloadIdentityPools/github-actions-pool"
}
import {
to = google_iam_workload_identity_pool_provider.github
id = "projects/solution-comunity/locations/global/workloadIdentityPools/github-actions-pool/providers/github-actions-provider"
}
import {
to = google_service_account.github_actions
id = "projects/solution-comunity/serviceAccounts/github-action-1078621049@solution-comunity.iam.gserviceaccount.com"
}
To point at the resource address that must already exist in HCL elsewhere in the configuration (the pool, provider, and service account resources have their own .tf files describing the desired state). id is the GCP resource path that the underlying provider knows how to look up. The IDs follow the canonical formats for each resource:
- google_iam_workload_identity_pool:
projects/<project>/locations/global/workloadIdentityPools/<pool-id> - google_iam_workload_identity_pool_provider:
projects/<project>/locations/global/workloadIdentityPools/<pool-id>/providers/<provider-id> - google_service_account:
projects/<project>/serviceAccounts/<email>
The execution order was: write the resource HCL describing each object as it currently exists in GCP, drop in the import.tf above, run terraform plan to confirm Terraform reports "import" actions and no replacements, then terraform apply to materialise the state. After apply succeeded, import.tf was removed from the configuration so subsequent plans do not re-attempt the import on every run.
The desired-state HCL for the imported resources looks like this (abbreviated):
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-actions-pool"
display_name = "GitHub Actions Pool"
description = "Pool for GitHub Actions OIDC federation"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-actions-provider"
display_name = "GitHub Actions Provider"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.ref" = "assertion.ref"
}
attribute_condition = "assertion.repository_owner == \"Solution-Community\""
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
resource "google_service_account" "github_actions" {
account_id = "github-action-1078621049"
display_name = "GitHub Actions deployer"
}
Important: the desired-state HCL must match the live resource configuration before you apply, otherwise the same apply that imports the resource will also try to "fix" it back to whatever the HCL says. The safe loop is: run plan, read every diff carefully, adjust HCL until plan shows pure imports with zero changes, then apply.
IAM Bindings (the iam_binding Module)
Importing the WIF pool, provider, and service account brings the identities into Terraform, but the federation itself only works once a binding gives the federated principal permission to impersonate the Google service account. We do that with a small reusable module checked into the repository at iam_binding/, called from the WIF root module. The repository is github.com/Solution-Community/terraform.
The module is intentionally generic: it takes a service account ID and a map of roles (list of members), and applies one google_service_account_iam_binding per role. The full module is two files:
iam_binding/main.tf
resource "google_service_account_iam_binding" "binding" {
for_each = var.bindings
service_account_id = var.service_account_id
role = each.key
members = each.value
}
iam_binding/variables.tf
variable "service_account_id" {
description = "The fully qualified name of the service account to apply bindings to"
type = string
}
variable "bindings" {
description = "A map of role to a list of members to bind to the service account"
type = map(list(string))
default = {}
}
Two implementation choices in this module are worth flagging because they affect operational behaviour:
google_service_account_iam_bindingis authoritative. For each (service account, role) tuple, Terraform owns the entire member list. Any member added out-of-band (via the console or another Terraform run) for that same role on that same service account will be removed on the next apply. This is the right choice for a federation trust because we want exactly one source of truth for who is allowed to impersonate the GitHub Actions SA. If you ever need additive semantics (one member added without disturbing the rest), usegoogle_service_account_iam_memberinstead.for_eachover a map of roles (members). Iterating with for_each (rather than count) keys each binding by role name, so adding or removing a role re-plans only that one binding instead of shuffling the entire list and triggering needless replacements.
The module is invoked from the WIF root module to grant the GitHub OIDC principal the workloadIdentityUser role on the imported service account. The call site looks like this:
# Project number is needed to construct the principalSet URI.
data "google_project" "this" {
project_id = "solution-comunity"
}
module "github_actions_iam" {
source = "./iam_binding"
service_account_id = google_service_account.github_actions.name
bindings = {
"roles/iam.workloadIdentityUser" = [
"principalSet://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/github-actions-pool/attribute.repository/Solution-Community/terraform",
]
}
}
A few things to note about the call site. service_account_id is passed as google_service_account.github_actions.name (which resolves to projects/<project>/serviceAccounts/<email>) so the binding follows the imported SA without hard-coding its email. The member uses principalSet:// (not principal://) because GitHub OIDC tokens identify a workflow run, not a static user; the principalSet URI selects every token whose attribute.repository claim equals Solution-Community/terraform — meaning every workflow in that repo can impersonate the SA, subject to the attribute condition on the provider. Tightening the trust further (per branch, per environment, per workflow) is done by adding more attribute mappings to the provider and using a more specific principalSet — for example .../attribute.repository_branch/Solution-Community/terraform/main.
Because the binding is created by Terraform from scratch (not imported from an existing GCP binding), there is no corresponding entry in import.tf for it. If you ever need to take over an existing manually-created binding, you would add an additional import block of the form:
import {
to = module.github_actions_iam.google_service_account_iam_binding.binding["roles/iam.workloadIdentityUser"]
id = "projects/solution-comunity/serviceAccounts/github-action-1078621049@solution-comunity.iam.gserviceaccount.com roles/iam.workloadIdentityUser"
}
The id format for google_service_account_iam_binding is <service-account-resource-name> <role> (space-separated), and the to address must include the for_each key in square brackets so Terraform knows which instance of the binding to associate.
Why a Different Terraform State Prefix for the WIF Import
The WIF resources were imported into a separate state file (a different prefix on the GCS backend) instead of being merged into the main project state. The reason is operational safety: WIF is a foundational, low-frequency, high-blast-radius piece of infrastructure, and the rest of the project state is high-frequency application infrastructure that changes daily. Mixing them creates two problems:
- First, every plan/apply on application infra would lock and read the WIF state, increasing the chance that a routine change accidentally proposes drift on the pool, provider, or service account — for example because a teammate has a slightly older provider version locally. Keeping WIF in its own prefix means application changes cannot even see those resources, let alone modify them.
- Second, the import operation itself is a special-case run: you want to be able to plan, apply, and, if needed, roll back the import in isolation, without those plan outputs being intermixed with unrelated application diffs and without holding a lock on the busy main state.
Concretely, the backend looks roughly like this:
# main project state (application infra, frequent changes)
terraform {
backend "gcs" {
bucket = "solution-comunity-tfstate"
prefix = "terraform/state/main"
}
}
# WIF bootstrap state (foundational, low-frequency)
terraform {
backend "gcs" {
bucket = "solution-comunity-tfstate"
prefix = "terraform/state/wif-bootstrap"
}
}
Using its own prefix also gives WIF its own lock object and its own state versioning history in the bucket, which makes audits and rollbacks of the federation trust easier to reason about. Once the import was complete, ownership of the WIF resources stays in the wif-bootstrap state; the main state references the resulting service account by email when it needs to grant it project-level roles, but it never manages the pool or provider directly.
Summary
We replaced static service-account keys for the GitHub Actions integration with Workload Identity Federation. We created a workload identity pool (github-actions-pool) and a GitHub OIDC provider (github-actions-provider) under project (solution-comunity), granted the federated principal permission to impersonate the github-action-1078621049 service account using the reusable iam_binding module, and brought the three pre-existing identities under Terraform management using terraform import blocks. The import was run against a dedicated wif-bootstrap state prefix so that the high-blast-radius federation trust is managed independently of day-to-day application infrastructure. Source of truth for everything described here is github.com/Solution-Community/terraform on the main branch.