ci/cd

Nobody has a service-account key

Nobody has a service-account key

There is one line in the bootstrap that decided how CI authenticates to this platform, and it was written before any CI existed:

resource "google_org_policy_policy" "disable_sa_key_creation" {
  name   = "${local.oneclub_folder}/policies/iam.disableServiceAccountKeyCreation"
  parent = local.oneclub_folder

  spec {
    rules {
      enforce = "TRUE"
    }
  }
}

Enforced at the folder, inherited by every project underneath. No service account in the organisation can have a JSON key minted for it. Not by me, not in a hurry, not "temporarily, for this one script".

So when it came time to let GitHub Actions deploy, federation was not a preference I had to justify against a simpler option. It was the only route that exists. That turns out to be a much better way to arrive at it than reading a best-practices page and feeling vaguely guilty.

The thing you are not doing

The default shape, the one every tutorial still shows, is: create a service account, download its key, paste the JSON into a GitHub repository secret, google-github-actions/auth with credentials_json.

What you have then is a bearer credential that never expires, sitting in a system that is not GCP, readable by anyone who can add a workflow file to that repository — which on most teams is anyone who can open a pull request, because a workflow triggered on pull_request_target runs with the repository's secrets. It does not rotate. Nothing tells you it leaked. Revoking it means finding every place it was pasted, and the honest answer to "where else is this key" is usually "I do not know."

Federation replaces the long-lived secret with a proof of identity that GitHub already issues.

What the exchange actually is

Every GitHub Actions job can ask GitHub for an OIDC token describing itself: which repository, which ref, which workflow, which actor. It is signed by GitHub, it is good for a few minutes, and it is useless to anyone who is not the audience it was minted for.

GCP's side is two resources. A pool, which is a namespace for external identities, and a provider inside it that says which issuer to trust and how to read its claims:

resource "google_iam_workload_identity_pool_provider" "github" {
  project                   = var.project_id
  workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
  attribute_condition       = "assertion.repository_owner == '${var.github_org}'"

  attribute_mapping = {
    "google.subject"             = "assertion.sub"
    "attribute.repository"       = "assertion.repository"
    "attribute.repository_owner" = "assertion.repository_owner"
    "attribute.ref"              = "assertion.ref"
    "attribute.actor"            = "assertion.actor"
  }

  oidc {
    issuer_uri = var.issuer_uri
  }
}

The workflow hands that token to GCP's STS, gets a short-lived federated credential back, and uses it to impersonate a service account. No key is created at any point, which is exactly why the org policy does not object.

Two parts of that resource do the actual security work, and they are easy to get backwards.

attribute_condition decides who may enter the pool at all. Without it, any GitHub repository in the world can present a valid token to your provider. It is a valid token — GitHub signed it — and the provider will happily mint a federated credential for it. Whether that credential can do anything is a separate question, but the door is open. Ours requires assertion.repository_owner == '1club-ai'.

attribute_mapping decides what you can write bindings against. Mapping repository is what makes this possible on the service account:

principalSet://iam.googleapis.com/projects/<n>/locations/global/workloadIdentityPools/<pool>/attribute.repository/1club-ai/1club

That is a principal set, not a principal: it names every identity that entered the pool with attribute.repository equal to that string. The roles/iam.workloadIdentityUser binding on the deployer service account is scoped to it. The condition on the provider is the coarse filter — one organisation. The binding on each service account is the real one — one repository, one environment.

Three identities, not one

The instinct is to create "the CI service account" and grant it what CI needs. What exists instead is three, and the split is deliberate.

A CI identity, in the admin project. It builds the container image and pushes it to Artifact Registry, tagged with the commit SHA. That is all it does. It lives next to the registry, not in a workload project, because the registry is central and building an image has nothing to do with any particular environment.

A deployer identity, one per environment. It updates the Cloud Run service in its own project. roles/run.admin there, plus roles/iam.serviceAccountUser on that environment's runtime account — because deploying a service that runs as another identity requires permission to act as it, which is its own small gate and a good one.

A runtime identity, one per environment. This is what the container actually runs as. Its whole grant today is roles/cloudsql.client.

resource "google_service_account_iam_member" "deployer_act_as_runtime" {
  service_account_id = google_service_account.runtime.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.deployer.email}"
}

The reason to separate the last two is the one I would defend hardest. A deployment identity is held by CI, which executes code from pull requests. A runtime identity can reach the database. If they are the same account, then every workflow in the repository can reach production data, and the review process for "can this branch read the members table" is the review process for "can this branch change a GitHub Action" — which is to say, none.

Staging and production are separate accounts in separate projects for the same reason, one level up. A staging deploy that has gone wrong cannot reach production, because the token it holds was scoped at the exchange and there is nothing to escalate to.

The permissions you discover by their absence

This is the part that costs you an afternoon each, so here they are written down.

The Cloud Run service agent needs reader on the registry, and it is not the account you are thinking of. When Cloud Run pulls an image it does not use your runtime service account. It uses the project's Cloud Run service agent, service-<PROJECT_NUMBER>@serverless-robot-prod.iam.gserviceaccount.com, and if that principal lacks roles/artifactregistry.reader on the repository, the deploy fails with a message about the image not being found.

It is found. It is right there. You can pull it yourself from your laptop. The error tells you nothing about permissions because Cloud Run will not confirm the existence of an image it is not allowed to see, which is correct behaviour and completely unhelpful the first time. The registry module therefore takes the project number from a data source and constructs the principal rather than letting anyone paste it:

reader_members = [
  "serviceAccount:service-${data.google_project.prod.number}@serverless-robot-prod.iam.gserviceaccount.com",
  "serviceAccount:${data.terraform_remote_state.prod_iam.outputs.runtime_sa_email}",
]

Moving a floating tag needs a permission writer does not have. After a successful deploy the pipeline re-tags the deployed digest as :staging, so there is always a name for "what is currently deployed". roles/artifactregistry.writer covers tags.create and tags.update but not tags.delete, and moving an existing tag deletes it from where it was. The role that does cover it is repoAdmin, which also permits deleting versions and editing the repository's IAM — far too much for a retag. So there is a custom role with exactly one permission in it:

resource "google_project_iam_custom_role" "tag_deleter" {
  project     = var.project_id
  role_id     = var.tag_deleter_role_id
  title       = "Artifact Registry Tag Deleter"
  permissions = ["artifactregistry.tags.delete"]
}

One permission. It felt absurd to write and it is the correct size.

A public Cloud Run service collides with a default you did not set. The global load balancer's serverless network endpoint group carries no caller identity, so the Cloud Run service needs roles/run.invoker granted to allUsers or the load balancer returns a 403 before a request ever reaches Express. But new GCP organisations enforce Domain Restricted Sharing by default, and allUsers is not a member of your customer ID, so the binding is rejected with a message about the member not belonging to a permitted domain.

The fix is a per-project exception to iam.allowedPolicyMemberDomains, scoped to the one workload project that hosts a public service. Not at the folder. Every other project keeps the restrictive default. It is worth being precise about what this is: it is not "the service is unauthenticated". Ingress is still pinned to internal-and-cloud-load-balancing by org policy, so the only path in is the load balancer, and authorisation is the application's job. The allUsers binding is plumbing that lets the load balancer talk to its own backend.

Wiring it without copying strings

The pool lives in one Terraform root and the service accounts live in six others. The temptation is to terraform output the principal-set prefix once and paste it into each environment's tfvars, which works and then quietly rots the first time anything is recreated.

Instead each environment's IAM root reads it:

data "terraform_remote_state" "iam_wif" {
  backend = "gcs"
  config = {
    bucket = "1club-tf-state"
    prefix = "admin/global/iam-wif"
  }
}

locals {
  deployer_principal_members = [
    for repo in var.deployer_repos :
    "${data.terraform_remote_state.iam_wif.outputs.principal_set_prefix}/attribute.repository/${repo}"
  ]
}

Each root keeps its own state under its own prefix in the same bucket, so this is a read across a boundary rather than a merge of two stacks into one. The environment declares which repositories may deploy it; the prefix that makes that a valid principal comes from the root that owns the pool.

On the GitHub side there are two repository variables — GCP_WIF_PROVIDER and GCP_DEPLOYER_SA — and they are variables rather than secrets, because neither is one. A provider resource name and a service account email are not confidential; what protects them is that presenting them without a token GitHub signed for the right repository gets you nothing.

What this does not fix

Short-lived credentials are not the same as small credentials. The federated token expires in minutes, but for those minutes a workflow holds everything the deployer service account can do. If the deployer had roles/editor the federation would be theatre. The role list is the security boundary; the token lifetime only bounds the theft window. That is why the deployer's grants are run.admin on one project, serviceAccountUser on one account, and artifactregistry.writer plus one custom permission on one repository.

The organisation-wide attribute condition is genuinely coarse. Any repository under 1club-ai can enter the pool. What stops a new repository from deploying production is only the workloadIdentityUser binding, which names specific repositories. That is the correct place for the fine-grained rule, but it means the provider condition is a backstop, not a gate, and I would rather say so than let the assertion.repository_owner line look like more protection than it is.

And the admin project is now a concentration. The pool, the registry and the Terraform state all live in it, which is a direct consequence of the four-project ceiling the organisation is built under. A separate CI/CD project was in the original design and did not survive.

But the specific failure this all exists to prevent — a credential sitting in a system that is not GCP, with no expiry and no revocation story — is not mitigated here. It is unavailable. Nobody has a key because nobody can make one.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev