In the OIDC deep-dive, I covered restricting GCP Workload Identity Federation using the actor or actor_id claim — letting you control not just which repository can authenticate but which human triggered the workflow. This is useful when you want to gate sensitive operations (manual prod deploys, key rotation, break-glass procedures) on specific individuals rather than just any push to main.
The problem: you can't practically maintain this by hand.
If you hardcode actor_id values directly in a google_iam_workload_identity_pool_provider resource, you're editing Terraform every time someone joins or leaves the team with deployment access. Teams change. People get hired, people leave, people change GitHub accounts. Within a few months the list is stale and you're either locking out legitimate team members or not removing access for people who have left.
Here's the pattern I landed on to make this maintainable: drive the actor_id list from a GitHub team using Terraform data sources, and use a GitHub App to automatically re-run the Terraform when team membership changes.
Why actor_id over actor
Before getting into the implementation — use actor_id, not actor. GitHub usernames can be changed or transferred. If someone changes their username, an actor == "oldusername" condition stops matching. If a username is released and claimed by someone else, the condition now matches the wrong person.
actor_id is the numeric internal ID assigned at account creation. It never changes and is never reassigned. This is the stable identifier.
The downside is that numeric IDs aren't human-readable in your Terraform, so you need the tooling to manage them rather than maintaining them manually.
The Terraform approach
The GitHub Terraform provider exposes team membership and user data as data sources. You can query the current members of a team, then query each member's user record to get their numeric ID, and use those IDs to build the WIF attribute condition dynamically.
Provider setup
# providers.tf
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
github = {
source = "integrations/github"
version = "~> 6.0"
}
}
}
provider "github" {
owner = var.github_org
# Authenticate via GITHUB_TOKEN env var, or:
# app_auth { id = var.github_app_id; installation_id = var.github_app_installation_id; pem_file = var.github_app_pem }
}
provider "google" {
project = var.gcp_project_id
}
Variables
# variables.tf
variable "github_org" {
type = string
description = "GitHub organization name"
}
variable "deployer_team_slug" {
type = string
description = "Slug of the GitHub team whose members are authorized to trigger deployments via WIF"
default = "platform-deployers"
}
variable "gcp_project_id" {
type = string
}
variable "wif_pool_id" {
type = string
description = "Workload Identity Pool ID"
}
Data sources: team → members → IDs
# github_team_members.tf
# Get the team and its current member list
data "github_team" "deployers" {
slug = var.deployer_team_slug
}
# For each member login, fetch their full user record to get the numeric ID
data "github_user" "deployers" {
for_each = toset(data.github_team.deployers.members)
username = each.key
}
locals {
# Build a map of login → numeric ID for clarity in outputs/debugging
deployer_id_map = {
for login, user in data.github_user.deployers :
login => user.id
}
# Flat list of numeric IDs (as strings — the OIDC claim is a string)
deployer_actor_ids = values(local.deployer_id_map)
# CEL condition: actor_id must be in the current team member list
# Fails closed: if the team is empty, deny everything rather than allow everything
actor_id_condition = length(local.deployer_actor_ids) > 0 ? (
"attribute.actor_id in ['${join("', '", local.deployer_actor_ids)}']"
) : "false"
# Combined condition: actor must be on the team AND coming from the right repo/environment
wif_attribute_condition = join(" && ", [
local.actor_id_condition,
"assertion.repository_owner_id == '${var.github_org_id}'"
])
}
The WIF provider resource
# workload_identity.tf
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = var.wif_pool_id
display_name = "GitHub Actions"
description = "Federated identity for GitHub Actions workflows"
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
display_name = "GitHub OIDC"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.actor" = "assertion.actor"
"attribute.actor_id" = "assertion.actor_id"
"attribute.repository" = "assertion.repository"
"attribute.environment" = "assertion.environment"
"attribute.job_workflow_ref" = "assertion.job_workflow_ref"
}
# This is the dynamic condition — regenerated every time Terraform runs
attribute_condition = local.wif_attribute_condition
}
The attribute_condition field accepts a CEL expression and is evaluated per-request when a workflow tries to authenticate. When Terraform runs, it queries the current team membership, builds the condition string with the current set of IDs, and updates the provider. Anyone added to the team gets access on the next Terraform run; anyone removed loses access on the next Terraform run.
A useful output for debugging
# outputs.tf
output "deployer_team_members" {
description = "Current team members and their actor IDs — who is authorized to authenticate via WIF"
value = local.deployer_id_map
# Not sensitive (IDs are public), but useful to see in plan output
}
output "wif_attribute_condition" {
description = "The generated CEL condition applied to the WIF provider"
value = local.wif_attribute_condition
}
Running terraform plan will show you exactly who is in scope and what condition will be applied before you apply anything.
The automation problem
The Terraform above works, but it only updates WIF when someone runs terraform apply. If someone leaves the team on Monday and the next planned apply isn't until Thursday, they have four days of access they shouldn't have.
The fix is a GitHub App that watches for team membership changes and triggers the Terraform workflow immediately.
Creating the GitHub App
In your GitHub org: Settings → Developer settings → GitHub Apps → New GitHub App.
- Name: something like
stormbane-wif-sync - Webhook URL: your Cloud Run function or Lambda URL (covered below)
- Webhook secret: generate a random secret, you'll need it in the function
- Permissions: Repository permissions → Actions → Read and Write (to trigger workflows)
- Subscribe to events:
TeamandMembership - Where can this app be installed: Only on this account
After creation, generate a private key and note the App ID and Installation ID.
The webhook handler
A minimal Cloud Run function (or Lambda) that receives the webhook and triggers the Terraform workflow:
# main.py — Cloud Run function
import functions_framework
import hmac
import hashlib
import os
import json
import urllib.request
WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] # App installation token
TF_REPO = os.environ["TF_REPO"] # e.g. "myorg/infra"
TF_WORKFLOW = os.environ["TF_WORKFLOW"] # e.g. "terraform-apply.yml"
@functions_framework.http
def handle_webhook(request):
# Verify signature
signature = request.headers.get("X-Hub-Signature-256", "")
body = request.get_data()
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return "Unauthorized", 401
event = request.headers.get("X-GitHub-Event")
payload = request.get_json()
# Only trigger on team membership changes
if event not in ("team", "membership"):
return "Ignored", 200
action = payload.get("action", "")
if action not in ("added", "removed", "edited"):
return "Ignored", 200
# Trigger the Terraform workflow
trigger_workflow()
return "OK", 200
def trigger_workflow():
url = f"https://api.github.com/repos/{TF_REPO}/actions/workflows/{TF_WORKFLOW}/dispatches"
data = json.dumps({"ref": "main"}).encode()
req = urllib.request.Request(
url,
data=data,
headers={
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
},
method="POST"
)
urllib.request.urlopen(req)
The GITHUB_TOKEN here should be a GitHub App installation token, not a personal access token. App tokens are scoped to the installation, expire in 1 hour, and don't belong to a human account that can leave. Generate it from the App's private key using the GitHub App JWT flow.
The Terraform workflow it triggers
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
workflow_dispatch: # triggered by webhook handler
push:
branches: [main]
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: prod-apply # requires reviewer approval for manual dispatches
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: "tf-apply-prod@my-project.iam.gserviceaccount.com"
- uses: hashicorp/setup-terraform@v3
- working-directory: terraform/wif
run: |
terraform init
terraform apply -auto-approve
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_APP_TOKEN }}
The full loop: someone is removed from the platform-deployers team → GitHub fires a membership webhook → Cloud Run function verifies the signature and calls the GitHub API → the Terraform workflow runs → Terraform queries the updated team, rebuilds the CEL condition, and applies the new WIF provider configuration. The removed user loses WIF access within however long the workflow takes — typically under two minutes.
Edge cases worth handling
Team empty after removal: The false fallback in the CEL condition means if the last person is removed from the team (or if the team query fails), WIF access is denied for everyone rather than granted to everyone. Fail closed.
Terraform apply failure: If the workflow fails (bad state, provider API error), the old condition stays in place — access isn't accidentally revoked. This is the behavior you want. Monitor the workflow for failures separately.
Multiple teams: The pattern extends naturally. If you have platform-deployers and senior-engineers who both need access, query both teams and union the ID lists:
data "github_team" "senior_engineers" {
slug = "senior-engineers"
}
data "github_user" "senior_engineers" {
for_each = toset(data.github_team.senior_engineers.members)
username = each.key
}
locals {
all_authorized_ids = toset(concat(
values({ for u in data.github_user.deployers : u.login => u.id }),
values({ for u in data.github_user.senior_engineers : u.login => u.id })
))
actor_id_condition = length(local.all_authorized_ids) > 0 ? (
"attribute.actor_id in ['${join("', '", local.all_authorized_ids)}']"
) : "false"
}
Stacking with other restrictions: The actor condition composes with environment and repo conditions using &&. You can require that the actor is on the authorized team AND the workflow is running in the prod-apply environment AND it's coming from the right repository — all enforced at the WIF layer before a GCP token is ever issued.
Why this is worth the setup
The alternative is either a fixed list of hardcoded IDs you maintain by hand (which goes stale) or no actor restriction at all (which means any workflow in the repo can assume the role). Neither is great.
The GitHub App approach means your access control policy lives in your GitHub team configuration — the thing you're already editing when you onboard and offboard people — and infrastructure reflects it automatically. The Terraform is the enforcement mechanism, not the source of truth.
It's a bit of plumbing to set up once. After that, it runs itself.