Google Workload Identity
Cuttlefish is an OIDC issuer. A job asks it for a short-lived, signed token that says which repository, ref, Cuttlefish project and trust tier it runs for, and Google exchanges that token for service-account credentials. No key is stored anywhere.
Overview
- Cuttlefish: give the node that authenticates
permissions.idToken: write(native workflows), orid-token: write(GitHub Actions jobs running live on Cuttlefish). - GCP: add an OIDC provider for
https://api.cuttlefish.shto your Workload Identity pool, with a condition that pins the repository and the Cuttlefish project. - Bind service accounts to the principal sets that fit each job: a plan account any run may use, and a deploy account only trusted runs of
mainmay use. - Run the job.
pipeline/gcp-auth0.3.2 orgoogle-github-actions/authdoes the exchange.
trust_tier == 'trusted'.Two ways a job gets a token
| Request protocol (use this) | Legacy OIDC_TOKEN | |
|---|---|---|
| Enabled by | permissions.idToken: write on the node or workflow | being a pipeline/gcp-auth node |
| Delivered as | ACTIONS_ID_TOKEN_REQUEST_URL and _TOKEN, GitHub's protocol | an OIDC_TOKEN secret minted when the node is leased |
| Runs that get it | every run except fork pull requests and runs whose GitHub context cannot be read | trusted runs; manual, API or scheduled runs with no GitHub context |
| Same-repository pull requests | yes, trust_tier untrusted | no |
| Claims | GitHub's claim set plus project_id, trust_tier, workflow_version_id, workflow_digest | sub pipeline:<project>:<run>:<node>, project_id, run_id, node_id |
| Audience | whatever the job requests | always https://api.cuttlefish.sh |
| Lifetime | 5 minutes | 10 minutes |
pipeline/gcp-auth 0.3.2 uses the grant whenever its node has one and falls back to OIDC_TOKEN. A trusted run is leased both; the grant wins because only its token carries the repository, ref and tier a provider condition should pin. Pushes to main and pull requests then authenticate the same way, and the provider tells them apart by trust_tier, event_name and ref.
The flow
Steps as text
- GitHub → Control plane: webhook: push or pull_request
- Control plane: classify trust_tier, record GitHub context
- Runner → Control plane: poll for work
- Control plane → Runner: lease + id-token grant (idToken: write nodes, never forks)
- Runner → gcp-auth node: start; grant in /run/secrets
- gcp-auth node → Control plane: GET /_apis/oidc/token?audience=… Bearer <request token>
- Control plane: attempt still running here? mint RS256 id-token (5 min)
- Control plane → gcp-auth node: {"value": "<id-token>"}
- gcp-auth node: write .gcp-credentials/ (credentials.json, oidc-token)
- Runner → Next node: start in the same workspace
- Next node → Google: STS token exchange (subject token = id-token)
- Google → Control plane: fetch discovery + JWKS
- Google: verify iss, aud, signature; mapping + condition; binding for the SA?
- Google → Next node: service-account access token (1 h)
The request token is a capability for one attempt: the control plane mints an id-token only while that attempt is running on the runner that leased it, and the runner redacts it from logs. Google fetches the issuer's discovery document and keys from https://api.cuttlefish.sh/.well-known/openid-configuration to verify the signature.
Steps as text
- Runner → Control plane: poll for work
- Control plane: gcp-auth node, and trusted (or manual, API, cron run)?
- Control plane → Runner: lease + secret OIDC_TOKEN (aud api.cuttlefish.sh, 10 min)
- Runner → gcp-auth node: start; OIDC_TOKEN in /run/secrets
- gcp-auth node: write .gcp-credentials/oidc-token
Who gets a token
| Case | Request protocol | Legacy OIDC_TOKEN |
|---|---|---|
| Push to the default or a protected branch, merge queue | yes (trusted) | yes |
| Pull request from the same repository | yes (untrusted) | no |
| Pull request from a fork | never | never |
| Manual, API or scheduled run with no GitHub context | yes (untrusted, no repository claims) | yes |
| GitHub Actions job in shadow mode | never | never |
| Rig that shares a Docker daemon with jobs | withheld by the runner | injected; avoid such rigs |
| Sibling node without the permission | no | no (it reads what gcp-auth wrote) |
Grants are kept only on micropod's Connect transport, where every attempt is its own micro-VM. The docker, podman and machine-over-docker executors, and micropod's shim transport, withhold them.
Configure Cuttlefish
Native workflow with pipeline/gcp-auth
workflows/<name>.yaml
spec:
nodes:
- id: gcp-auth
taskRef:
name: pipeline/gcp-auth
version: "0.3.2"
permissions:
idToken: write # this node only; siblings get nothing
inputs:
method:
value: wif
workloadIdentityProvider:
secretRef: { name: WORKLOAD_IDENTITY_PROVIDER }
serviceAccount:
secretRef: { name: SERVICE_ACCOUNT_EMAIL }
# audience defaults to the issuer's origin, https://api.cuttlefish.sh
- id: terraform-plan
taskRef:
name: pipeline/terraform
version: "0.3.0" # sources .gcp-credentials/env.sh itself
| Input | Meaning |
|---|---|
| method | wif (default) or service-account-key |
| workloadIdentityProvider | projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>, or the WORKLOAD_IDENTITY_PROVIDER secret |
| serviceAccount | the account to impersonate, or the SERVICE_ACCOUNT_EMAIL secret |
| audience | audience of a request-protocol token; default the issuer's origin. Must be in the provider's allowedAudiences |
gcp-auth writes .gcp-credentials/ into the run's workspace: credentials.json (an external_account config reading the token file), oidc-token and env.sh. Later nodes source env.sh; pipeline/terraform and pipeline/docker-build do so themselves.
GitHub Actions jobs running live on Cuttlefish
.github/workflows/<name>.yml
permissions:
contents: read
id-token: write
jobs:
plan:
runs-on: [self-hosted, cuttlefish, linux]
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
# omit when the provider leaves allowedAudiences unset
audience: https://api.cuttlefish.sh
A live job whose permissions grant id-token: write gets the same grant, so google-github-actions/auth works unchanged. Shadow-mode jobs never get one.
Any other node
An inline node that authenticates itself
- id: deploy
image: google/cloud-sdk:slim
permissions: { idToken: write }
script: |
curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://api.cuttlefish.sh" \
| jq -r .value > /tmp/oidc
cat > /tmp/creds.json <<EOF
{"type": "external_account",
"audience": "//iam.googleapis.com/${WIF_PROVIDER}",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SA_EMAIL}:generateAccessToken",
"credential_source": {"file": "/tmp/oidc"}}
EOF
gcloud auth login --cred-file=/tmp/creds.json
Configure GCP
1. The provider
Create the Cuttlefish provider
PROJECT_ID=<gcp-project>; POOL_ID=<pool>
PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
REPO=<owner>/<repo>; REPO_ID=$(gh api "repos/$REPO" --jq .id)
CF_PROJECT=<cuttlefish project id>
gcloud iam workload-identity-pools providers create-oidc cuttlefish \
--project="$PROJECT_ID" --location=global --workload-identity-pool="$POOL_ID" \
--issuer-uri="https://api.cuttlefish.sh" \
--allowed-audiences="https://api.cuttlefish.sh" \
--attribute-mapping="google.subject=assertion.sub,\
attribute.repository=assertion.repository,\
attribute.project_id=assertion.project_id,\
attribute.trust_tier=assertion.trust_tier,\
attribute.event_name=assertion.event_name,\
attribute.ref=assertion.ref,\
attribute.deploy=assertion.repository + ':' + assertion.project_id + ':' + assertion.trust_tier + ':' + assertion.ref,\
attribute.plan=assertion.repository + ':' + assertion.project_id" \
--attribute-condition="assertion.repository == '${REPO}' \
&& assertion.repository_id == '${REPO_ID}' \
&& assertion.project_id == '${CF_PROJECT}'"
| Setting | Value | Why |
|---|---|---|
| issuer-uri | https://api.cuttlefish.sh, no trailing slash | Google compares iss byte for byte and fetches the keys from it |
| allowed-audiences | https://api.cuttlefish.sh | gcp-auth's default audience; leave unset only if every job requests the provider's resource URL |
| attribute.deploy | repository:project:trust_tier:ref | lets a deploy account bind to trusted runs of main only |
| attribute.plan | repository:project | lets a plan-only account accept pull requests |
| condition | repository, repository_id, project_id | refuses other repositories and other Cuttlefish projects mapped to this one |
The condition leaves room for pull requests; the bindings decide what each tier may impersonate. A pool that only deploys can require trust_tier == 'trusted' and ref == 'refs/heads/main' in the condition instead. Never copy a GitHub provider's condition: it does not pin the Cuttlefish project.
2. Bindings: split plan from apply
Bind each account to the principal set it may serve
POOL="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}"
# Plan: any run of the repository in this Cuttlefish project, pull requests included.
gcloud iam service-accounts add-iam-policy-binding "terraform-plan@${PROJECT_ID}.iam.gserviceaccount.com" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/${POOL}/attribute.plan/${REPO}:${CF_PROJECT}"
# Apply / deploy: only trusted runs of main.
gcloud iam service-accounts add-iam-policy-binding "terraform-apply@${PROJECT_ID}.iam.gserviceaccount.com" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/${POOL}/attribute.deploy/${REPO}:${CF_PROJECT}:trusted:refs/heads/main"
Give the plan account read-only roles. A pull request is untrusted by definition, so it must never be able to impersonate an account that changes infrastructure.
attribute.repository/<owner>/<repo> grant made for a GitHub provider also matches Cuttlefish tokens for that repository, from any project and tier. Audit each account's policy with gcloud iam service-accounts get-iam-policy before adding the provider.3. Point the workflow at it
Set the project secrets the workflow reads: WORKLOAD_IDENTITY_PROVIDER to the provider's resource name and SERVICE_ACCOUNT_EMAIL. Plan and apply jobs name different accounts.
Prove it
From a node with permissions.idToken: write
AUD_PROVIDER="//iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/cuttlefish"
TOKEN=$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://api.cuttlefish.sh" | jq -r .value)
# What the provider will see (no signature check here):
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| jq '{iss, aud, sub, repository, project_id, trust_tier, event_name, ref}'
curl -fsS https://sts.googleapis.com/v1/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d requested_token_type=urn:ietf:params:oauth:token-type:access_token \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d scope=https://www.googleapis.com/auth/cloud-platform \
-d audience="$AUD_PROVIDER" -d subject_token="$TOKEN" | jq 'has("access_token")'
true means the provider accepts the issuer, audience and condition. Then prove the refusals: a pull request must get a 403 from generateAccessToken on the deploy account, and a workflow in another Cuttlefish project mapped to the same repository must fail the exchange.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| ERROR: no OIDC token | gcp-auth got neither a grant nor OIDC_TOKEN | add permissions.idToken: write to the gcp-auth node (fork pull requests never get one) |
| ERROR: OIDC_TOKEN is not set | gcp-auth 0.3.1 or older on an untrusted run | pin pipeline/gcp-auth 0.3.2 and grant idToken: write |
| permissions.idToken is not available on this rig | the rig shares a Docker daemon with jobs | run on a micropod (Connect) rig |
| STS: audience does not match | the token's aud is not in allowedAudiences | set the audience input to an allowed value |
| STS: rejected by the attribute condition | repository, project or tier does not match | decode the token (Prove it) and compare with the condition |
| STS: attribute mapping failed | the mapping reads a claim the token lacks (legacy token, or run_number) | use the request protocol; map only claims Cuttlefish emits |
| iam.serviceAccounts.getAccessToken denied | no workloadIdentityUser binding matches the principal | bind the right principal set (plan or deploy) |
| the next node fails minutes later | the 5-minute id-token in the credentials file expired before the exchange | keep auth directly before the node that uses it, or authenticate in that node |
| 401 from /_apis/oidc/token | the request token's attempt is no longer running on this runner | request the token within the attempt that holds the grant |
Google's client libraries exchange once and cache the result for an hour. A job that runs longer than an hour after authenticating cannot refresh from the expired token file, so authenticate in the node that runs long.