Keyless in a Day, Google Workspace Terraform and Three Faults That All Failed Closed

I brought my Google Workspace org under Terraform in a day, and the beauty was it required zero service-account keys. The CI authenticates to Google via Workload Identity Federation (WIF), OIDC end to end, nothing to rotate and nothing to leak. That part worked. The build also hit three separate live faults on the way, and all three of them failed CLOSED, each with an error precise enough to debug from, which sells the architecture harder than a clean run ever could have.

The rule that started this was never to create a Google service-account key. Downloaded JSON keys are forever-credentials, they don’t expire on their own, they end up pasted into CI secrets, they survive every laptop they are copied to, and revocation is something you go do after the incident. WIF replaces the key with a federation dance. The CI job presents its OIDC token, Google’s STS exchanges it for short-lived credentials, those impersonate a service account, and for the Workspace Admin APIs the service account then needs domain-wide delegation (DWD) to act as an admin user. Four hops, four things that can be misconfigured, and I managed to misconfigure three of them in one take.

Fault one: the service account needs signJwt on ITSELF

The DWD leg works by having the service account sign a JWT asserting the admin subject, and the impersonation chain does that through the IAM Credentials API’s signJwt call. The federated principal impersonating the service account is not enough on its own, and no diagram I read while setting this up says so, the service account needs roles/iam.serviceAccountTokenCreator granted ON ITSELF before the chain can mint that assertion. A service account holding a role on its own resource looks like a mistake to anybody reviewing it, I had to sit down and write the rationale comment defending it, and without the self-grant the whole exchange dies at the signing step anyway.

The failure mode was a clean, explicit IAM error naming the missing permission and the principal that needed it. Ten minutes to fix, most of them spent not believing the answer was really “grant the account a role on itself.”

Fault two: the DWD scope mismatch 401

Domain-wide delegation is authorized in the Workspace admin console as a client ID plus an EXACT list of OAuth scopes. The Terraform provider requests the scopes it needs, and if the console-side list doesn’t match, one scope missing, you don’t get told which scope, you get a bare 401 with no body from the token exchange.

This is the one that eats hours if you don’t already know its shape, because a 401 with no body looks exactly like every other auth failure in the chain and there are four legs it could have come from. The STS exchange succeeds, the impersonation succeeds, the Workspace API call is the one that dies, and WHERE it stops is the only tell… because that localizes the problem to DWD, and DWD failures are almost always scope-list drift between what the provider asks for and what the console authorized (At least that is what I am told…). The fix was diffing the provider’s requested scopes against the console list until the missing one fell out. The prevention is treating the console scope list as configuration that mirrors a committed list in the repo, since the console side cannot be Terraformed and will happily sit there out of sync forever.

Fault three: the WIF condition rejected a legitimate exchange

The WIF pool condition pins which repository’s OIDC tokens are accepted, and my condition was written tighter than the token the CI job actually presented. It failed closed again, a legitimate job got refused at the front door with an explicit condition-evaluation error, I loosened the condition to exactly the intended repo claim shape, re-ran, and voilà, it worked like nothing was ever wrong.

Three faults, zero incidents

All three faults produced DENIALS. Nothing fell back to a broader credential, nothing cached a stale success, nothing decided I was probably fine and waved the call through. Every hop in the chain is an explicit grant, so every misconfiguration surfaces as an explicit refusal with a name attached to it, and I never once had to guess which leg had failed. A downloaded key does none of that. It works the moment it exists, it works from whatever laptop it got copied to, it works long after the person who downloaded it stopped needing it, and it never once tells you which of those is happening.

The day ended with a finding I did not enjoy writing down. My plan-stage CI runs were holding apply-grade Google credentials, because the WIF setup authenticated plan and apply identically. A read-only split for the plan lane is designed and deferred, rationale written, since the day’s budget went to getting keyless working at all. I am noting it here because taking the key away did nothing at all about how much that identity can do, it only made the privilege legible enough for me to write down.

If you are holding a service-account JSON key for Workspace automation right now, the migration cost me a day, the faults you hit will name themselves as they arrive, and there is nothing left on the far side of it to rotate.