Terraform GKE Workload Identity — the binding that is computed
The IAM member is not a string you type. It is a function of project, namespace, and name.
<!-- hal:authoritative:yaml -->
§I — Frame
Three days ago this arc taught the order Terraform touches things. A launch template changed, the auto scaling group had to be replaced, and the default order put a hole in the fleet. The lesson was about when. The cloud was AWS.
Today the cloud is GKE, and the question is a different one: what string does Terraform write when a Kubernetes ServiceAccount is allowed to become a Google Service Account?
Most first drafts answer by typing the string.
member = "serviceAccount:prod-apps.svc.id.goog[payments/checkout]"
That line looks like configuration. It is a guess that happens to be shaped like configuration. The project id, the Workload Identity pool, the namespace, and the KSA name are four facts the module already has. Concatenating them by hand is how the binding drifts the first time any one of the four is renamed.
Call the failure the binding that is computed, not declared. The IAM member is not a value an author supplies. It is a value Terraform produces from values an author already supplied.
Eleven days ago the K8s track taught the other half of this story. The 08-04 lesson walked the request path (authenticate, authorize, admit), named the pod's two identities, and put Workload Identity Federation on the GKE metadata server so a JSON key never had to sit in a Secret. That lesson is about who the pod is, inside the cluster and at the Google boundary. This lesson is about the Terraform that writes the trust. It does not reopen RBAC, CSRs, or the metadata-server hop.
It also does not reopen provider aliases. The 07-28 lesson already spent Brikman Ch.7 on that. One google provider, one cluster, one project. The difficulty is the member string, not the plugin.
§II — Foundations
Two principals, one computed member
A Google Service Account is an IAM principal. A Kubernetes ServiceAccount is a cluster principal. Workload Identity is the claim that a specific KSA, in a specific namespace, on a specific project's Workload Identity pool, may act as a specific GSA.
Google encodes that claim as an IAM member on the GSA:
serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]
The GSA's own email is also computed:
ACCOUNT_ID@PROJECT_ID.iam.gserviceaccount.com
Neither string is a name you invent. Both are functions of inputs the module already required. Brikman's module chapter is the right frame for that fact, even though the book never mentions GKE. Module Locals exist so a module can compute values the caller should not be asked to type (Ch. 4, pp. 211-212). Module Outputs exist so the computed values can leave the module as a contract, not as a comment (Ch. 4, pp. 214-216). The GSA email and the WI member belong in locals first and in outputs second. They do not belong in a variable with a default that someone will paste from a wiki.
Why a literal member is a silent bug
A hardcoded member survives terraform validate. It survives a plan that looks empty. It fails at runtime, when the pod asks the metadata server for a token and Google answers that this KSA is not on the GSA.
The usual ways it goes wrong:
- The project id in the member is the folder-local nickname, not the project that owns the cluster's Workload Identity pool.
- The namespace was
paymentsin staging andpayments-prodin production, and the module was copied with the member left alone. - The KSA was renamed in the Helm chart and nobody touched the
.tf. - The cluster was created with Workload Identity off. The annotation is present. The IAM member is present. The metadata server has no pool to consult.
Terraform cannot see any of those, because none of them is drift against a resource attribute Terraform owns. The configuration still matches state. The world does not match the configuration's intent. That is the same class of hole the 08-12 lesson named for desired_capacity: Terraform is doing its job, and the job was pointed at the wrong owner.
The four writes, and which of them are computed
A working binding is four writes. Two of them produce values. Two of them consume those values.
| Write | What it is | Computed or declared |
|---|---|---|
google_service_account | The GSA | Account id is declared. Email is computed. |
google_service_account_iam_member | roles/iam.workloadIdentityUser on that GSA | The member string is computed. |
kubernetes_service_account (or a pre-existing KSA) | The cluster principal | Name and namespace are declared. |
Annotation iam.gke.io/gcp-service-account | The KSA's pointer at the GSA | The value is the computed email. |
IAM without the annotation: the GSA trusts a KSA that never asks. Annotation without IAM: the KSA asks, the GSA refuses. Authors who "set up Workload Identity" and ship only one of the two have shipped a configuration that plans clean and runs denied.
The 08-04 lesson already said why the annotation exists (the metadata server reads it). Today the only new claim is that the value it carries is google_service_account.this["checkout"].email, not a string literal, and that the IAM member on the other side is a for-expression over the same map.
§III — Mechanism
Locals compute; resources consume
Brikman introduces module locals as the place for values derived from inputs, so the rest of the module can read a name once (Ch. 4, pp. 211-212). The WI module's locals block is the whole design:
locals {
bindings = {
for name, w in var.workloads :
name => {
gsa_id = w.gsa_id
namespace = w.namespace
ksa = w.ksa
member = "serviceAccount:${var.project_id}.svc.id.goog[${w.namespace}/${w.ksa}]"
}
if w.enable_workload_identity
}
}
That is a Ch. 5 for-expression producing an object, with the if filter Ch. 5 names in the conditionals section (pp. 253-255 and pp. 266-267). The 07-25 HCL lesson used for_each and dynamic to decide how many resources exist. This block decides what value each resource receives. The resource addresses still come from for_each = local.bindings. The member does not.
The day's Dev lesson is this block, taught as language. Here it is the reason a payments checkout pod can be renamed in one map entry and have both sides of the trust move together.
Lab 16 of the Terraform Pro set is adjacent, not identical: it wants filtered for_each and map-shaped outputs, which is the iteration half. The filter in local.bindings is the same if clause. The lab does not mention GKE. It is cited as the exam-shaped drill for the filter, not as a Workload Identity lab.
Nested input wants flatten first
Callers do not always send a flat map. A platform team often sends namespaces, each with a list of KSAs:
variable "namespaces" {
type = map(object({
ksas = list(object({
name = string
gsa_id = string
}))
}))
}
A for expression over that map yields a list of lists. for_each will not take it. Lab 27 exists because this reshape is a Pro-exam skill: flatten the nested structure, then build a stable key, then iterate (README: nested collection transforms, stable map keys). The WI version is:
locals {
pairs = flatten([
for ns, spec in var.namespaces : [
for ksa in spec.ksas : {
key = "${ns}/${ksa.name}"
namespace = ns
ksa = ksa.name
gsa_id = ksa.gsa_id
member = "serviceAccount:${var.project_id}.svc.id.goog[${ns}/${ksa.name}]"
}
]
])
bindings = { for p in local.pairs : p.key => p }
}
The key is ${ns}/${ksa.name} because that is the identity Google already uses inside the member string. Reusing it as the for_each key means a renamed KSA is a create-and-destroy of one binding, not a shift of every binding after it. That is the 07-25 lesson's "a name is not an index" claim, applied to a value the 07-25 lesson did not compute.
The four resources, written against the map
resource "google_service_account" "workload" {
for_each = local.bindings
account_id = each.value.gsa_id
display_name = each.key
}
resource "google_service_account_iam_member" "wi" {
for_each = local.bindings
service_account_id = google_service_account.workload[each.key].name
role = "roles/iam.workloadIdentityUser"
member = each.value.member
}
resource "kubernetes_service_account" "workload" {
for_each = local.bindings
metadata {
name = each.value.ksa
namespace = each.value.namespace
annotations = {
"iam.gke.io/gcp-service-account" = google_service_account.workload[each.key].email
}
}
}
Three things to notice, then stop.
The member is each.value.member, which was computed in the local. It is not reconstructed in the resource block. Reconstructing it in two places is how the annotation and the IAM member disagree.
The annotation value is .email, which is a computed attribute of google_service_account. Terraform knows the email at plan time because the email is a function of account_id and project, not a server-assigned random. If a future provider change made email unknown until apply, the kubernetes provider would see a known-after-apply annotation and the KSA would be written twice. That is a plan-time fact, and it is the same class of fact the day's Cert lesson will press on: some results exist at plan, some do not.
workloadIdentityUser is granted on the GSA, not on the project. A project-level grant of that role is the wrong resource. The plan will still create an IAM member. The pod will still be denied, or worse, a broader principal will be trusted. Read the resource address: google_service_account_iam_member, service_account_id = ...name. If the address is google_project_iam_member, the module is wrong.
What the cluster must already be
Workload Identity is a cluster feature, not only a binding. On GKE Standard the node pool (or the cluster) must be created with Workload Identity enabled; the metadata server is what the 08-04 lesson described. On Autopilot it is on by default. Terraform that writes bindings against a cluster created with the feature off will apply cleanly. The runtime failure will look like a missing annotation.
The cluster resource is out of scope for this module on purpose. A WI module that also creates the cluster will want to replace the cluster when a binding changes, or will grow a lifecycle block that the 08-12 lesson already taught people to reach for too early. Bindings take the cluster as a data source or as an input. They do not own it.
§IV — The module contract
Brikman's output section is the reason the computed values leave the module (Ch. 4, pp. 214-216). A caller that needs to grant the GSA roles/storage.objectViewer on a bucket should not reconstruct the email.
output "gsa_emails" {
value = {
for name, sa in google_service_account.workload :
name => sa.email
}
}
output "wi_members" {
value = {
for name, b in local.bindings :
name => b.member
}
}
Those outputs are themselves for-expressions. They are maps keyed by the same logical names the caller used on the way in. Lab 16's success criterion (shape outputs as maps keyed by logical names, not as lists) is this pair of blocks. A list output would reintroduce index shift the moment one workload was removed.
The input side of the contract is a map, not a list, for the same reason. var.workloads is map(object({...})). The 08-03 HCL lesson typed that object. This lesson does not retype it. It consumes it.
A caller in live/prod/payments then writes:
module "wi" {
source = "git::https://github.com/hedronite/tf-modules.git//gke-workload-identity?ref=v1.4.0"
project_id = var.project_id
workloads = {
checkout = {
gsa_id = "pay-checkout"
namespace = "payments"
ksa = "checkout"
enable_workload_identity = true
}
indexer = {
gsa_id = "pay-indexer"
namespace = "payments"
ksa = "indexer"
enable_workload_identity = true
}
}
}
resource "google_storage_bucket_iam_member" "indexer" {
bucket = var.receipts_bucket
role = "roles/storage.objectViewer"
member = "serviceAccount:${module.wi.gsa_emails["indexer"]}"
}
The bucket grant uses the computed email. The WI member was never visible to this caller, and it should not have been: the caller is granting GCS to a GSA, not re-stating the KSA trust. Two different member strings, two different functions of the same module. Mixing them up is the other silent 403.
§V — Failure modes the plan will not show
Project id versus project number. The Workload Identity pool name uses the project id (prod-apps), not the project number (123456789012). Some Google APIs want the number. This one wants the id. A helper that interpolates data.google_project.this.number into the member will plan a binding Google will never honor.
The default compute service account is still in the pod spec. Workload Identity does not fire if the Pod still mounts a JSON key, or if the node still uses the legacy metadata path, or if serviceAccountName is omitted and the Pod runs as default while the binding was written for checkout. Terraform of the binding cannot see the Pod spec unless the Pod is also in this state. It usually is not.
IAM Conditions that look like a second filter. An IAM condition on workloadIdentityUser can restrict the member further. If the condition references an attribute the WI token does not carry, every exchange fails. The plan shows the condition as a string. It does not evaluate it.
**roles/iam.workloadIdentityUser on the wrong resource.** Covered above; restated because it is the one that survives code review. Reviewers see "IAM member, Workload Identity, looks right." The address is the tell.
Creating the GSA in project A and the cluster in project B. Shared-VPC and fleet-host shapes do this on purpose. The member's PROJECT_ID is the project that owns the cluster's Workload Identity pool, which is the cluster project, not necessarily the GSA project. The GSA email's project is the GSA project. Two project ids, two functions, one module that takes both as inputs. A module that takes one project_id and uses it for both will work until the first shared-VPC landing, then fail in a way that looks like a typo.
§VI — What this rung adds
The TF Ops arc has now taught state, modules, providers, environments, the pipeline, policy, replacement order, and the computed binding. The through-line today is the same sentence the Dev and Cert lessons will say at two other altitudes: the value that is computed, not declared.
In this module the computed values are the GSA email and the WI member. In the HCL lesson they are the object a for-expression constructs. In the Cert lesson they are the run-task verdict and the speculative plan: results that exist because a plan ran, not because a .tf file named them.
The operator who types the member string is doing the compiler's job by hand, and doing it worse. Write the inputs. Let the local compute the claim. Grant the role to the claim. Annotate the KSA with the email. Output both so the next grant does not start guessing.
Related
- Archmagus-Stack/Polyglot-Dev/HCL/2026-08-15-hcl-for-expressions-object-constructors-and-the-value-that-is-computed-not-declared/lesson
- Archmagus-Stack/Cert-Prep/HashiCorp/2026-08-15-terraform-pro-hcp-run-tasks-speculative-plans-and-the-result-that-is-computed-at-plan-time/lesson
- Archmagus-Stack/01-Earth-DevOps/Synthesis-Lessons/2026-08-12-terraform-resource-lifecycle-on-aws-create-before-destroy-ignore-changes-replace-triggered-by-and-zero-downtime-asg-replacement/lesson
- Archmagus-Stack/01-Earth-DevOps/Synthesis-Lessons/2026-08-04-kubernetes-identity-and-authorization-on-gke-serviceaccounts-rbac-and-workload-identity-federation/lesson
🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-15 at Fajr. Trio #90.