Workload Identity Federation (GCP)
CI that authenticates to Google Cloud with no service account key anywhere, and an org policy that makes creating one impossible even for a project owner. Four federation paths built side by side so the trade-offs are visible rather than asserted, deployed against a live organization and destroyed the same day.
/01Problem
A service account key is a JSON file holding a private key that does not expire. It authenticates as its service account from anywhere on the internet, forever, until someone notices and revokes it. Leaked keys are found in public repositories within minutes and are a routine root cause of cloud incidents.
The usual mitigations are procedural: do not commit keys, rotate them, scan for them. Every one of them depends on nobody making a mistake. Federation removes the key entirely, but it introduces a subtler failure: a federation pool configured without constraints is worse than a key, because it trusts every token its issuer signs, and GitHub's issuer signs one for every workflow run in every repository on GitHub.
/02Approach
- GitHub Actions to GCP through direct resource access, where a principalSet holds IAM roles on the bucket, registry, and secret and no service account exists at all.
- The same token exchanged for a service account through roles/iam.workloadIdentityUser, built only so the two can be compared and the choice defended.
- AWS to GCP through a pool that verifies a signed GetCallerIdentity request against AWS STS, which is why that provider takes an account ID rather than an issuer URL.
- GCP to AWS through web identity federation, where AWS treats accounts.google.com as a built-in provider, so trust pins to the service account's numeric unique ID rather than its email.
/03The control is the attribute condition
Attribute mapping renames claims. It does not decide who gets in. The provider pins both repository and owner, so a token from any other repository is refused at the pool before any IAM binding is evaluated. Deny at the door, then scope inside it.
Authority then splits across two attributes of the same pool: read binds on attribute.repository, write binds on attribute.ref. That is what lets a pull request from a fork read without being able to write, and it is why pinning the sub claim to a single value, which is what one Checkov rule wants, would have been a downgrade rather than a hardening. The waiver is written into both the code and the README with that reasoning.
/04Making the claim enforceable
Two org policy constraints, disableServiceAccountKeyCreation and disableServiceAccountKeyUpload, are applied to the workload project. With both enforced, nobody can create or upload a key there, including a project owner. That is the difference between "we do not use keys" and "keys cannot exist here."
Secret Manager and Artifact Registry share one customer-managed key, so disabling a single key version revokes both at once with no IAM edit and nothing deleted. State lives in a separate project no federated identity can reach, because state describes the shape of everything and CI never needs to read it.
/05What the first apply taught
- resourcemanager.organizationAdmin does not include folders.create. It administers IAM policy; it does not create hierarchy.
- Org policy administration is a separate role again, orgpolicy.policyAdmin, and is also absent from organizationAdmin.
- User credentials with no quota project bill orgpolicy calls to Google's shared OAuth client project, failing with SERVICE_DISABLED on a project ID that looks alarming and is not yours. Fixed with billing_project and user_project_override on the provider.
- The Secret Manager service agent does not exist at the moment the KMS grant references it. It resolves on retry; the lag is real and undocumented in the obvious places.
/06Verified, then destroyed
The deployed provider's attribute condition was read back from the API and matched what was written. Both constraints reported enforce = True. Both projects had zero default networks. The secret was confirmed bound to the customer-managed key.
The check that matters ran last: creating a service account key as project owner was refused with constraints/iam.disableServiceAccountKeyCreation named in the violation. Everything was then destroyed, leaving both projects in DELETE_REQUESTED with billing unlinked, the folder gone, and no residual buckets.