Skip to content

GKE Workload Identity

This implementation guide assumes you have an existing GKE cluster with Workload Identity enabled and want to install RunWhen Local with no long-lived service-account keys. RunWhen Local authenticates through Application Default Credentials (ADC), which on GKE means the pod’s bound Google Service Account (GSA) is used automatically.

This is the GCP equivalent of EKS Workload Identity and Azure Managed Identity.


1. Prerequisites

  • Existing GKE cluster with Workload Identity enabled (cluster + a node pool using the GKE metadata server).
  • kubectl, gcloud, and helm configured for that cluster and project.
  • Permissions to create IAM service accounts, IAM policy bindings, and Kubernetes resources in the cluster.

2. Namespace and Kubernetes Base

Create the namespace and the service account RunWhen Local will run as:

Terminal window
kubectl create namespace runwhen-local
kubectl create serviceaccount runwhen-local -n runwhen-local

The Helm chart can also create the SA; if you create it yourself, keep the name/namespace below so the rest of the guide matches.


3. Create a Google Service Account (GSA)

Create one Google Service Account that RunWhen Local and the runner will use:

Terminal window
export PROJECT_ID=my-gcp-project
export GSA_NAME=runwhen-local-discovery
gcloud iam service-accounts create "$GSA_NAME" \
--project="$PROJECT_ID" \
--description="Service Account for RunWhen Discovery" \
--display-name="RunWhen Discovery Service Account"

Record the GSA email: $GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com


4. Attach IAM Permissions to the GSA

Grant the discovery roles RunWhen Local needs. At minimum:

Terminal window
# Broad read access for cloud resource discovery
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/viewer"
# GKE cluster list/get (required for auto-discovery)
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/container.viewer"

For least privilege, replace roles/viewer with the per-service viewer roles your generation rules actually touch (e.g. roles/compute.viewer, roles/storage.objectViewer, roles/pubsub.viewer, roles/iam.securityReviewer).

For the optional Cloud Asset Inventory accelerator (broader resource coverage), additionally:

Terminal window
gcloud services enable cloudasset.googleapis.com --project="$PROJECT_ID"
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:$GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/cloudasset.owner"

CAI is not required — the typed baseline collectors run normally without it.


5. Bind the KSA to the GSA (Workload Identity)

Allow the Kubernetes Service Account to impersonate the GSA:

Terminal window
gcloud iam service-accounts add-iam-policy-binding \
"$GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:$PROJECT_ID.svc.id.goog[runwhen-local/runwhen-local]" \
--project="$PROJECT_ID"

Then annotate the KSA with the GSA email:

Terminal window
kubectl annotate serviceaccount runwhen-local -n runwhen-local \
iam.gke.io/gcp-service-account="$GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com"

The RunWhen Local pod must run as this KSA (serviceAccountName: runwhen-local).


6. Grant the GSA Access to the GKE Cluster

For Kubernetes discovery, the GSA needs to be able to call the GKE cluster API. The roles/container.viewer role (granted in step 4) covers container.clusters.list and container.clusters.get, which is what RunWhen Local uses to fetch cluster endpoints and CA certs at discovery time.

For the actual Kubernetes API calls (listing namespaces, pods, etc.), RunWhen Local mints a short-lived OAuth bearer token from the GSA and uses it as the kubeconfig user credential. Ensure the GSA (or the Google group it belongs to) has appropriate RBAC access in the cluster. At minimum, create a ClusterRoleBinding for read access:

Terminal window
kubectl create clusterrolebinding runwhen-local-viewer \
--clusterrole=view \
--user="$GSA_NAME@$PROJECT_ID.iam.gserviceaccount.com"

7. workspaceInfo Configuration

RunWhen Local discovers the cluster via the GKE Container API and then uses the same credentials to read the cluster. Put this in a workspaceInfo.yaml that you’ll load as a ConfigMap (step 8).

With Workload Identity, no credential fields are needed — leaving saSecretName, serviceAccountKey, and applicationCredentialsFile empty means ADC (and therefore Workload Identity) is used:

workspaceName: "my-runwhen-workspace"
workspaceOwnerEmail: "you@example.com"
defaultLocation: location-01
defaultLOD: detailed
gcpIndexerBackend: gcpapi # default; native SDK, no CloudQuery
cloudConfig:
gcp:
# No credential fields -> ADC -> Workload Identity
projects:
- my-gcp-project
gkeClusters:
autoDiscover: true
discoveryConfig:
projectId: my-gcp-project
# explicit clusters are merged with discovered ones
clusters:
- name: prod-cluster
location: us-east1
defaultNamespaceLOD: detailed
namespaceLODs:
kube-system: none
codeCollections:
- repoURL: "https://github.com/runwhen-contrib/google-cloud-codecollection"
branch: "main"
- repoURL: "https://github.com/runwhen-contrib/rw-cli-codecollection"
branch: "main"

Adjust projects and gkeClusters to match your environment. For a single explicit cluster instead of auto-discovery, set autoDiscover: false and list the cluster under clusters with name and location.

Do not set GOOGLE_APPLICATION_CREDENTIALS in the pod environment when using Workload Identity. That env var forces ADC to read a key file and short-circuits the metadata-server path.


8. Load workspaceInfo into the Cluster

Create a ConfigMap from the file you created in step 7:

Terminal window
kubectl create configmap workspaceinfo -n runwhen-local --from-file=workspaceInfo.yaml

If the ConfigMap already exists, replace it:

Terminal window
kubectl create configmap workspaceinfo -n runwhen-local --from-file=workspaceInfo.yaml \
--dry-run=client -o yaml | kubectl apply -f -

9. Install RunWhen Local with Helm

Use the RunWhen Local Helm chart and point it at the existing namespace, service account, and workspaceInfo ConfigMap.

  • Service account: use the same runwhen-local SA in runwhen-local namespace so it gets the GSA credentials via Workload Identity.
  • Runner: set runner deployment and runner pods to use the same runwhen-local service account so they share the identity.

Example values (customize image, upload, and code collections as needed):

# values.yaml (excerpt)
workspaceBuilder:
serviceAccount:
create: false # we created it manually in step 2
name: runwhen-local
serviceAccountRoles:
clusterRoleView:
enabled: true
workspaceInfo:
useExistingConfigMap: true
existingConfigMapName: workspaceinfo
configMap:
create: false
# With Workload Identity, cluster discovery and auth both flow through ADC.
# Do NOT enable inClusterAuth — GKE discovery uses the GCP Container API via
# the GSA token, not the pod's in-cluster SA token.
discoveryKubeconfig:
inClusterAuth:
enabled: false
runner:
enabled: true
runEnvironment:
deployment:
serviceAccount: runwhen-local
pod:
serviceAccount: runwhen-local
# ... rest of runner config (codeCollections, controlAddr, etc.)

Install:

Terminal window
helm upgrade --install runwhen-local ./path-to-chart \
-n runwhen-local \
-f values.yaml

10. Verify

  1. GCP identity from a pod:

    Terminal window
    kubectl run -it --rm debug --restart=Never -n runwhen-local \
    --serviceaccount=runwhen-local \
    --image=google/cloud-sdk:slim -- \
    gcloud auth list

    Or verify the metadata server is returning the GSA token:

    Terminal window
    kubectl run -it --rm debug --restart=Never -n runwhen-local \
    --serviceaccount=runwhen-local \
    --image=google/cloud-sdk:slim -- \
    curl -s -H "Metadata-Flavor: Google" \
    "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"

    The identity should be the GSA you created (e.g. runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com).

  2. GKE cluster access:

    The same pod should be able to list nodes:

    Terminal window
    kubectl run -it --rm debug --restart=Never -n runwhen-local \
    --serviceaccount=runwhen-local \
    --image=bitnami/kubectl:latest -- \
    get nodes
  3. RunWhen Local: Check discovery and runner pods; logs should show no GCP auth errors and successful GKE/Kubernetes discovery.


Runtime Authentication for Generated Tasks

Generated tasks (codebundles) re-authenticate using the same ADC path. The secret-provider templates resolve the gcp_adc auth type to:

For GCP cloud resources (CLI):

secretsProvided:
- name: gcp_credentials
workspaceKey: gcp:adc@cli

For GKE clusters (kubeconfig):

secretsProvided:
- name: kubeconfig
workspaceKey: gcp:adc@kubeconfig:prod-cluster/us-east1

The gcp:adc@* workspaceKeys consume the pod’s ambient ADC (Workload Identity) at task-execution time — no secret injection needed, provided the runner pod uses the same annotated KSA. If your runner is a different pod than the discovery pod, repeat the KSA annotation + IAM binding for that runner namespace/service-account.


Summary Checklist

StepWhat you did
1Confirmed existing GKE cluster with Workload Identity enabled
2Created namespace runwhen-local and SA runwhen-local
3Created Google Service Account for RunWhen discovery
4Granted IAM roles (roles/viewer, roles/container.viewer) to the GSA
5Bound KSA to GSA via IAM policy + KSA annotation
6Granted GSA read access to the GKE cluster (ClusterRoleBinding)
7Wrote workspaceInfo.yaml with no credential fields and gkeClusters config
8Created ConfigMap workspaceinfo from workspaceInfo.yaml in runwhen-local
9Installed Helm with SA runwhen-local, existing ConfigMap, and runner using same SA
10Verified GSA identity and cluster access from a pod using runwhen-local SA

Single namespace, single service account (runwhen-local), single Google Service Account, and one IAM binding give both the RunWhen Local controller and the runner cluster-reader access and GCP discovery using Workload Identity on your existing GKE cluster.


Extending Beyond the Default Viewer Role

The base setup grants roles/viewer (cloud-level read-only) and a view ClusterRoleBinding (Kubernetes read-only). Many real-world workflows need more than read access — restarting workloads, deleting pods for run ops, reconciling Flux resources, or reading custom resources that the built-in view ClusterRole doesn’t aggregate.

The recommended pattern keeps GCP IAM minimal (roles/viewer for cloud discovery only) and extends all Kubernetes-level access via in-cluster RBAC. This avoids pulling in broad GKE IAM group roles (container.developeredit, container.admincluster-admin) which grant cluster-wide write or admin that a discovery workload should not have.

Why not GKE IAM group roles?

GKE maps predefined IAM roles to in-cluster groups:

GCP IAM roleGKE groupIn-cluster equivalent
roles/container.viewergcp-viewersview ClusterRole
roles/container.developergcp-developersedit ClusterRole
roles/container.admingcp-adminscluster-admin

container.developer (edit) grants create/update/delete on all built-in resources across every namespace — far too broad for a discovery SA. container.admin is cluster-admin. Instead, grant roles/viewer in GCP IAM and layer narrowly-scoped in-cluster RBAC for exactly the write operations you need.

Example: Discovery SA with targeted run-ops

This example shows a RunWhen Local deployment that needs:

  1. Cluster-wide read — discover resources in all namespaces
  2. Targeted delete — reset a specific stateful workload (e.g. a database pod and its PVC) to clear corrupted state
  3. Flux reconciliation — patch a single Flux Kustomization to trigger a reconcile

All three are bound to the GSA email as a kind: User subject — not the Kubernetes ServiceAccount. With GKE Workload Identity, the Kubernetes API server sees the GSA email as the authenticated identity, so RBAC must reference the GSA (User), not the KSA (ServiceAccount).

Helm values — annotate the SAs

The Helm chart creates the workspace-builder and runner service accounts. Add the annotation so they federate to the GSA:

workspaceBuilder:
serviceAccount:
create: true
annotations:
iam.gke.io/gcp-service-account: runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com
runner:
serviceAccount:
create: true
annotations:
iam.gke.io/gcp-service-account: runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com

Both SAs federate to the same GSA. The GSA’s GCP IAM stays at roles/viewer only.

In-cluster RBAC — bind to the GSA identity (User)

Apply these manifests to the cluster (via Flux, kubectl, or your GitOps tool of choice). The subjects use kind: User with the GSA email — the identity the Kubernetes API server sees when a Workload Identity-federated pod makes a call:

# --- 1. Cluster-wide read (same as base setup) ---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: runwhen-discovery-viewer
subjects:
- kind: User
name: runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: view
apiGroup: rbac.authorization.k8s.io
---
# --- 2. Targeted delete in a specific namespace ---
# Allow resetting a specific stateful workload by deleting its pod and PVC.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: runwhen-discovery-targeted-delete
namespace: my-app
rules:
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
resourceNames: ["data-app-db-0"]
verbs: ["delete"]
- apiGroups: [""]
resources: ["pods"]
resourceNames: ["app-db-0"]
verbs: ["delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: runwhen-discovery-targeted-delete
namespace: my-app
subjects:
- kind: User
name: runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com
apiGroup: rbac.authorization.k8s.io
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: runwhen-discovery-targeted-delete
---
# --- 3. Flux Kustomization reconcile (single resource) ---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: runwhen-discovery-flux-reconcile
namespace: flux-system
rules:
- apiGroups: ["kustomize.toolkit.fluxcd.io"]
resources: ["kustomizations"]
resourceNames: ["my-app-sync"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: runwhen-discovery-flux-reconcile
namespace: flux-system
subjects:
- kind: User
name: runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com
apiGroup: rbac.authorization.k8s.io
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: runwhen-discovery-flux-reconcile

Key design choices:

  • kind: User subjects — the GSA email is the identity GKE presents to the Kubernetes API. Binding to kind: ServiceAccount (the KSA) would have no effect; the API server authenticates the federated GSA, not the KSA.
  • resourceNames scoping on the Role — the delete permission only applies to the specific pod and PVC named, not all resources of that type in the namespace.
  • Namespace-scoped Roles — the Flux patch is limited to a single Kustomization CR by name in flux-system; no cluster-wide CRD write.
  • Single subject — the GSA email covers all pods federated to it (both workspace-builder and runner), so no need to list multiple SAs.
  • Cross-cluster portability — because RBAC binds to the GSA email (User) rather than a KSA, the same Role/RoleBinding works on any cluster where a pod federated to that GSA calls the API. No local KSA needs to exist in the target cluster.

Custom resource discovery (CRDs)

The built-in view ClusterRole covers standard Kubernetes resources but not custom resource instances (CRs). If your discovery needs to read CRs — Flux Kustomizations, GitRepositories, CrunchyData PostgresClusters, or RunWhen’s own SLX resources — you have two options:

Create a ClusterRole with the rbac.authorization.k8s.io/aggregate-to-view: "true" label. Kubernetes automatically merges it into the built-in view ClusterRole, so every SA bound to view inherits the CR read permissions — no extra RoleBinding needed:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: aggregate-runwhen-view
labels:
rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
# Flux CD
- apiGroups: ["kustomize.toolkit.fluxcd.io"]
resources: ["*"]
verbs: ["get", "list", "watch"]
- apiGroups: ["source.toolkit.fluxcd.io"]
resources: ["*"]
verbs: ["get", "list", "watch"]
# CrunchyData Postgres operator
- apiGroups: ["postgres-operator.crunchydata.com"]
resources: ["*"]
verbs: ["get", "list", "watch"]
# Metrics
- apiGroups: ["metrics.k8s.io"]
resources: ["pods", "nodes"]
verbs: ["get", "list"]

This is the cleanest approach for discovery-only workloads — one ClusterRole, no per-SA bindings, automatically inherited by anything bound to view.

Option B: Explicit ClusterRoleBinding

If you prefer not to modify the view ClusterRole globally, create a standalone ClusterRole + ClusterRoleBinding bound to the GSA identity:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: runwhen-crd-viewer
rules:
- apiGroups: ["kustomize.toolkit.fluxcd.io"]
resources: ["*"]
verbs: ["get", "list", "watch"]
# ... add other apiGroups as needed
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: runwhen-crd-viewer
subjects:
- kind: User
name: runwhen-local-discovery@my-gcp-project.iam.gserviceaccount.com
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: runwhen-crd-viewer
apiGroup: rbac.authorization.k8s.io

Use Option B when the CRD read access should be scoped to only the RunWhen GSA and not every principal bound to view.

Cross-project discovery

When RunWhen Local runs in a GKE cluster in project B but needs to discover resources in project A (e.g. a shared services project), the GSA lives in project A and is impersonated by the cluster in project B via Workload Identity.

  1. Create the GSA in project A and grant it roles/viewer on project A.
  2. Grant roles/iam.workloadIdentityUser on the GSA to both the project-B cluster’s workspace-builder and runner KSAs — the workspace-builder runs discovery, and the runner executes generated tasks that also need GCP access:
    Terminal window
    gcloud iam service-accounts add-iam-policy-binding \
    "discovery-sa@project-a.iam.gserviceaccount.com" \
    --role="roles/iam.workloadIdentityUser" \
    --member="serviceAccount:project-b.svc.id.goog[runwhen-local/workspace-builder]"
    gcloud iam service-accounts add-iam-policy-binding \
    "discovery-sa@project-a.iam.gserviceaccount.com" \
    --role="roles/iam.workloadIdentityUser" \
    --member="serviceAccount:project-b.svc.id.goog[runwhen-local/runner]"
  3. Grant roles/viewer on project B to the same GSA (so it can discover project B’s cloud resources too):
    Terminal window
    gcloud projects add-iam-policy-binding project-b \
    --member="serviceAccount:discovery-sa@project-a.iam.gserviceaccount.com" \
    --role="roles/viewer"
  4. Annotate both KSAs in the project-B cluster with the project-A GSA email (same Helm serviceAccount.annotations as above — applied to both workspaceBuilder and runner).
  5. Apply in-cluster RBAC in the project-B cluster for any Kubernetes-level access (same pattern as the targeted example above). Remember: the RBAC binds to the GSA email (kind: User), so a single binding covers both the workspace-builder and runner pods automatically.

The GSA’s cloud-level roles/viewer on both projects lets it enumerate GCP resources and list GKE clusters. In-cluster RBAC in each cluster controls what Kubernetes resources it can actually read/write. No JSON keys are exchanged — Workload Identity handles the cross-project federation.