Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/skills/agentic-workflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Load these files from `github/gh-aw` (they are not available locally).
- `.github/aw/github-agentic-workflows.md`
- `.github/aw/github-mcp-server.md`
- `.github/aw/instructions.md`
- `.github/aw/linter-workflows.md`
- `.github/aw/llms.md`
- `.github/aw/loop.md`
- `.github/aw/lsp.md`
Expand Down
85 changes: 85 additions & 0 deletions docs/adr/48185-google-vertex-ai-wif-auth-for-gemini-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ADR-48185: Google Vertex AI Workload Identity Federation Auth for Gemini Engine

**Date**: 2026-07-26
**Status**: Accepted
**Deciders**: Gemini engine maintainers

---

### Context

Enterprise Gemini workloads on Google Vertex AI and the Gemini Enterprise Agent Platform require keyless
authentication rather than a static `GEMINI_API_KEY` long-lived secret. Storing long-lived API keys in
GitHub repository secrets creates a credential-rotation burden and a higher blast radius on compromise.
The `engine.auth` mechanism already supports a `type: github-oidc` + `provider: <name>` pattern for
Anthropic WIF (Claude engine), establishing a precedent for exchanging short-lived GitHub OIDC tokens
for provider-specific credentials. The goal is to give Gemini the same keyless alternative without
disrupting existing `GEMINI_API_KEY`-based workflows.

### Decision

We add a `provider: gcp` discriminator to `engine.auth` that enables Google Cloud Workload
Identity Federation (WIF) for the Gemini engine. When `type: github-oidc` and `provider: gcp` are
set with the three required fields (`workload-identity-provider`, `service-account`, `project`), the
compiler skips the `GEMINI_API_KEY` secret requirement, suppresses static-key validation, switches
the Gemini CLI to the Vertex AI backend (`GOOGLE_GENAI_USE_VERTEXAI=true`), and emits
`AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER` and `AWF_AUTH_GCP_SERVICE_ACCOUNT` environment variables
for the AWF api-proxy sidecar. The `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` vars are set
directly for the Gemini CLI (defaulting `location` to `us-central1`). The Vertex AI env vars are
applied after any `engine.env` merge so they cannot be overridden by users. The feature is
implemented by extending `EngineAuthConfig` with four new fields (`GoogleWorkloadIdentityProvider`,
`GoogleServiceAccount`, `GoogleProject`, `GoogleLocation`), parsing them in `engine_config_parser.go`,
adding `validateGCPWIFEngineAuth()` for compile-time required-field validation, and branching on
`isGeminiVertexWIF()` in the Gemini engine compilation path. Existing `GEMINI_API_KEY` flows are
unchanged. The provider name `gcp` was chosen to match the AWF firewall's existing OIDC provider
contract (`AWF_AUTH_PROVIDER=gcp`).

### Alternatives Considered

#### Alternative 1: Keep `GEMINI_API_KEY` as the Only Auth Path

The Gemini engine continues to require a static API key, with no keyless option. This avoids any
change to the auth pipeline and the schema. It was not chosen because it blocks enterprise Vertex AI
workloads that prohibit long-lived secrets and cannot adopt the tool without a keyless path. The
Anthropic WIF precedent already established that a keyless alternative is the right direction for
enterprise engine adoption.

#### Alternative 2: Generic Provider Plugin / Extension Point

Introduce a fully generic WIF provider registry so any engine can declare its own WIF fields without
touching core auth structs. This would be more extensible but requires a significant refactor of the
auth pipeline, parser, and schema — far more complexity than warranted for a single new provider.
The simpler flat-field extension approach (consistent with how Anthropic and Azure WIF fields are
structured today) achieves the same goal with minimal risk and easier auditability.

### Consequences

#### Positive
- Enables enterprise Vertex AI / Gemini Enterprise workloads that require zero long-lived secrets in
the repository.
- Consistent with the existing `type: github-oidc` + `provider:` discriminator pattern for Anthropic
WIF, reducing conceptual surface area for users already familiar with Claude engine keyless auth.
- Short-lived OIDC tokens exchanged via Google Cloud WIF reduce blast radius vs. a static API key
stored indefinitely in repo secrets.

#### Negative
- Users must pre-configure a Google Cloud Workload Identity Pool, Provider, and service account with
appropriate Vertex AI permissions — substantially more setup than providing a single `GEMINI_API_KEY`.
- Four new fields added to `EngineAuthConfig` struct and the `engine.auth` JSON schema; the parser and
compiler now have an additional branch (`isGeminiVertexWIF`) to maintain and test for each future
Gemini engine change.
- The `service-account` and `project` keys in `engine.auth` are shared key names without a `gcp-`
prefix, which could cause ambiguity if a future provider also uses these field names for different
semantics. This is accepted as a known trade-off consistent with the existing `engine.auth` pattern.

#### Neutral
- The `location` field is optional and defaults to `us-central1` when omitted. The compile-time
validation (`validateGCPWIFEngineAuth()`) only checks for the presence of the required fields
(`workload-identity-provider`, `service-account`, `project`); it does not validate that the region
string is valid, so an invalid region will fail at runtime rather than compile time.
- The AWF api-proxy sidecar handles the actual OIDC token exchange with Google Cloud; this ADR covers
only the compiler-side changes that emit the required `AWF_AUTH_GCP_*` environment variables.

---

*ADR accepted. Provider name changed from `google` to `gcp` to match the AWF firewall OIDC contract.*
65 changes: 62 additions & 3 deletions docs/src/content/docs/reference/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ Configure the authentication method your engine needs before running your first

### Gemini

- **Required secret:** [`GEMINI_API_KEY`](#gemini_api_key)
- **Alternative:** None
- **Notes:** API key from Google AI Studio
- **Standard:** [`GEMINI_API_KEY`](#gemini_api_key) — static API key from Google AI Studio
- **Keyless alternative:** [`engine.auth` Google WIF](#google-workload-identity-federation-wif) — short-lived OIDC token via Google Cloud Workload Identity Federation; no long-lived secret on the repo

Most workflows will run without any additional secrets or additional authentication beyond this one engine secret.

Expand Down Expand Up @@ -339,6 +338,66 @@ See also [AI Engines](/gh-aw/reference/engines/#available-coding-agents) for add

---

### Google Workload Identity Federation (WIF)

Workload Identity Federation lets workflows authenticate with Gemini using short-lived
GitHub OIDC tokens exchanged for Google Cloud credentials instead of a long-lived `GEMINI_API_KEY` secret.
When WIF is active, the compiler suppresses the static-key requirement, switches the Gemini CLI to the
Vertex AI backend (`GOOGLE_GENAI_USE_VERTEXAI=true`), and emits `AWF_AUTH_GCP_*` environment variables
consumed by the AWF firewall api-proxy sidecar.

**Prerequisites:**
- A Google Cloud Workload Identity Pool and Provider configured for GitHub Actions
(see [Google Cloud WIF for GitHub Actions](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines))
- A Google Cloud service account with Vertex AI User permissions, granted impersonation from the WIF provider
- `permissions: id-token: write` in the workflow job

**Frontmatter:**

```yaml wrap
permissions:
contents: read
id-token: write

engine:
id: gemini
auth:
type: github-oidc
provider: gcp
workload-identity-provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER
service-account: my-sa@my-project.iam.gserviceaccount.com
project: my-project
# Optional: Cloud region (defaults to us-central1)
# location: us-central1
```

**Fields:**

| Field | Required | Description |
|---|---|---|
| `workload-identity-provider` | ✅ | Full resource name of the Google Cloud WIF provider |
| `service-account` | ✅ | Service account email to impersonate |
| `project` | ✅ | Google Cloud project ID for Vertex AI inference |
| `location` | Optional | Cloud region for Vertex AI (default: `us-central1`) |

**Emitted environment variables:**

The compiler maps each field to an env var passed to the AWF api-proxy sidecar and sets the
Vertex AI backend env vars for the Gemini CLI:

| Field | Env var |
|---|---|
| `type: github-oidc` | `AWF_AUTH_TYPE=github-oidc` |
| `provider: gcp` | `AWF_AUTH_PROVIDER=gcp` |
| `workload-identity-provider` | `AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER` |
| `service-account` | `AWF_AUTH_GCP_SERVICE_ACCOUNT` |
| `project` | `GOOGLE_CLOUD_PROJECT` |
| `location` | `GOOGLE_CLOUD_LOCATION` (defaults to `us-central1`) |

Additionally, `GOOGLE_GENAI_USE_VERTEXAI=true` is always set when GCP WIF is active.

---

## Troubleshooting auth errors

Common authentication errors and how to resolve them:
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Set `engine:` in your workflow frontmatter and configure the corresponding secre
| [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli) (default) | `copilot` | [`copilot-requests: write`](/gh-aw/reference/auth/#copilot-requests-write-permission) (recommended) or [`COPILOT_GITHUB_TOKEN`](/gh-aw/reference/auth/#copilot_github_token) |
| [Claude by Anthropic (Claude Code)](https://www.anthropic.com/index/claude) | `claude` | [`ANTHROPIC_API_KEY`](/gh-aw/reference/auth/#anthropic_api_key) (standard) or [`engine.auth` Anthropic WIF](/gh-aw/reference/auth/#anthropic-workload-identity-federation-wif) (keyless) |
| [OpenAI Codex](https://openai.com/blog/openai-codex) | `codex` | [OPENAI_API_KEY](/gh-aw/reference/auth/#openai_api_key) |
| [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | [GEMINI_API_KEY](/gh-aw/reference/auth/#gemini_api_key) |
| [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | [`GEMINI_API_KEY`](/gh-aw/reference/auth/#gemini_api_key) (standard) or [`engine.auth` Google WIF](/gh-aw/reference/auth/#google-workload-identity-federation-wif) (keyless) |
| [OpenCode](https://opencode.ai) (experimental) | `opencode` | [COPILOT_GITHUB_TOKEN](/gh-aw/reference/auth/#copilot_github_token) |
| [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (experimental) | `pi` | [COPILOT_GITHUB_TOKEN](/gh-aw/reference/auth/#copilot_github_token) (default); switches to provider-specific secret when `model:` uses `provider/model` format |

Expand Down
60 changes: 60 additions & 0 deletions pkg/cli/compile_wif_google_gemini_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//go:build integration

package cli

import (
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCompileGeminiWIFGoogle verifies that a workflow using Google
// Workload Identity Federation (engine.auth.type=github-oidc with
// provider=google) compiles successfully without requiring GEMINI_API_KEY,
// and that the WIF fields are correctly emitted as env vars in the lock file.
func TestCompileGeminiWIFGoogle(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

// Copy the canonical Google WIF workflow fixture into the test's .github/workflows dir
srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-gemini-wif-google.md")
dstPath := filepath.Join(setup.workflowsDir, "test-gemini-wif-google.md")

srcContent, err := os.ReadFile(srcPath)
require.NoError(t, err, "Failed to read source workflow file %s", srcPath)
require.NoError(t, os.WriteFile(dstPath, srcContent, 0644), "Failed to write workflow to test dir")

// Compile the workflow - it must succeed (exit 0) without GEMINI_API_KEY.
cmd := exec.Command(setup.binaryPath, "compile", dstPath)
output, err := cmd.CombinedOutput()
require.NoError(t, err, "Gemini WIF Google workflow must compile without error:\n%s", string(output))

// Verify the lock file was created and contains the expected WIF env vars.
lockFilePath := filepath.Join(setup.workflowsDir, "test-gemini-wif-google.lock.yml")
lockContent, err := os.ReadFile(lockFilePath)
require.NoError(t, err, "Expected lock file %s to be created", lockFilePath)
lockStr := string(lockContent)

// All WIF fields from the fixture must be emitted as env vars in the compiled lock
// file. Checking for "KEY: value" pairs ensures both the key and the value round-trip
// correctly through the schema → parser → compiler pipeline.
assert.Contains(t, lockStr, "AWF_AUTH_PROVIDER: gcp", "lock file should contain AWF_AUTH_PROVIDER=gcp")
assert.Contains(t, lockStr, "AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github",
"lock file should contain AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER")
assert.Contains(t, lockStr, "AWF_AUTH_GCP_SERVICE_ACCOUNT: my-sa@my-project.iam.gserviceaccount.com",
"lock file should contain AWF_AUTH_GCP_SERVICE_ACCOUNT")

// Vertex AI backend env var and project/location must be set
assert.Contains(t, lockStr, "GOOGLE_GENAI_USE_VERTEXAI: true", "lock file should set GOOGLE_GENAI_USE_VERTEXAI=true")
assert.Contains(t, lockStr, "GOOGLE_CLOUD_PROJECT: my-project", "lock file should set GOOGLE_CLOUD_PROJECT")
assert.Contains(t, lockStr, "GOOGLE_CLOUD_LOCATION: us-central1", "lock file should set GOOGLE_CLOUD_LOCATION")

// GEMINI_API_KEY must NOT appear in the lock file when WIF is configured
assert.NotContains(t, lockStr, "GEMINI_API_KEY", "lock file must not contain GEMINI_API_KEY when Google WIF is configured")

t.Logf("Google WIF workflow compiled successfully to %s", lockFilePath)
}
1 change: 1 addition & 0 deletions pkg/cli/data/agentic_workflows_fallback_aw_files.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"github-agentic-workflows.md",
"github-mcp-server.md",
"instructions.md",
"linter-workflows.md",
"llms.md",
"loop.md",
"lsp.md",
Expand Down
26 changes: 26 additions & 0 deletions pkg/cli/workflows/test-gemini-wif-google.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
on:
workflow_dispatch:

permissions:
contents: read
id-token: write

engine:
id: gemini
auth:
type: github-oidc
provider: gcp
workload-identity-provider: projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github
service-account: my-sa@my-project.iam.gserviceaccount.com
project: my-project
location: us-central1

network: defaults

timeout-minutes: 5
---

# Google Vertex AI WIF schema test

Echo "ok".
18 changes: 17 additions & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12434,7 +12434,7 @@
},
"provider": {
"type": "string",
"description": "Optional WIF provider discriminator. Recognized values are 'azure' and 'anthropic'."
"description": "Optional WIF provider discriminator. Recognized values are 'azure', 'anthropic', and 'gcp'."
},
"federation-rule-id": {
"type": "string",
Expand All @@ -12451,6 +12451,22 @@
"workspace-id": {
"type": "string",
"description": "Anthropic WIF workspace ID (e.g., ws_...)."
},
"workload-identity-provider": {
"type": "string",
"description": "Google Cloud WIF workload identity provider resource name (e.g., projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER)."
},
"service-account": {
"type": "string",
"description": "Google Cloud service account email to impersonate via WIF (e.g., my-sa@my-project.iam.gserviceaccount.com)."
},
"project": {
"type": "string",
"description": "Google Cloud project ID used for Vertex AI / Gemini Enterprise inference."
},
"location": {
"type": "string",
"description": "Google Cloud region for Vertex AI inference (e.g., us-central1). Defaults to us-central1 when omitted."
}
},
"required": ["type"],
Expand Down
30 changes: 30 additions & 0 deletions pkg/workflow/compiler_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar
{logMessage: "Validating max-daily-ai-credits frontmatter", validateFn: func() error { return validateMaxDailyAICFrontmatter(workflowData) }},
{logMessage: "Validating private-to-public-flows string value", validateFn: func() error { return validatePrivateToPublicFlowsStringValue(workflowData) }},
{logMessage: "Validating private-to-public-flows server IDs", validateFn: func() error { return validatePrivateToPublicFlowsServerIDs(workflowData) }},
{logMessage: "Validating GCP WIF engine auth required fields", validateFn: func() error { return validateGCPWIFEngineAuth(workflowData) }},
}
// This validation is intentionally outside the table below because strict mode
// turns the same validation result into either an error or a warning.
Expand Down Expand Up @@ -424,3 +425,32 @@ func hasWeightedTrafficExperiment(configs map[string]*ExperimentConfig) bool {
}
return false
}

// validateGCPWIFEngineAuth returns an error when engine.auth declares
// provider=gcp with type=github-oidc but is missing one or more of the three
// required fields (workload-identity-provider, service-account, project).
// Without these fields the WIF exchange cannot succeed and GEMINI_API_KEY will
// also be absent, causing a guaranteed runtime failure that is hard to diagnose.
func validateGCPWIFEngineAuth(workflowData *WorkflowData) error {
if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Auth == nil {
return nil
}
auth := workflowData.EngineConfig.Auth
if auth.Type != "github-oidc" || auth.Provider != "gcp" {
return nil
}
var missing []string
if auth.GoogleWorkloadIdentityProvider == "" {
missing = append(missing, "workload-identity-provider")
}
if auth.GoogleServiceAccount == "" {
missing = append(missing, "service-account")
}
if auth.GoogleProject == "" {
missing = append(missing, "project")
}
if len(missing) > 0 {
return fmt.Errorf("engine.auth with provider=gcp requires the following fields: %s", strings.Join(missing, ", "))
}
return nil
}
9 changes: 8 additions & 1 deletion pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ type InlineEngineDriver struct {
type EngineAuthConfig struct {
Type string
Audience string
Provider string // "azure" or "anthropic"
Provider string // "azure", "anthropic", or "gcp"
// Azure WIF fields
AzureTenantID string
AzureClientID string
Expand All @@ -132,6 +132,11 @@ type EngineAuthConfig struct {
AnthropicOrganizationID string
AnthropicServiceAccountID string
AnthropicWorkspaceID string
// Google / Vertex AI WIF fields
GoogleWorkloadIdentityProvider string
GoogleServiceAccount string
GoogleProject string
GoogleLocation string
}

// NetworkPermissions represents network access permissions for workflow execution
Expand Down Expand Up @@ -727,6 +732,8 @@ func applyEngineAuthEnv(config *EngineConfig) {
setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_ORGANIZATION_ID", config.Auth.AnthropicOrganizationID)
setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID", config.Auth.AnthropicServiceAccountID)
setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_WORKSPACE_ID", config.Auth.AnthropicWorkspaceID)
setEngineAuthEnv(config.Env, "AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER", config.Auth.GoogleWorkloadIdentityProvider)
setEngineAuthEnv(config.Env, "AWF_AUTH_GCP_SERVICE_ACCOUNT", config.Auth.GoogleServiceAccount)
}

func setEngineAuthEnv(env map[string]string, key, value string) {
Expand Down
12 changes: 12 additions & 0 deletions pkg/workflow/engine_config_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ func parseEngineAuthConfig(authObj map[string]any) *EngineAuthConfig {
if s, ok := authObj["workspace-id"].(string); ok {
auth.AnthropicWorkspaceID = s
}
if s, ok := authObj["workload-identity-provider"].(string); ok {
auth.GoogleWorkloadIdentityProvider = s
}
if s, ok := authObj["service-account"].(string); ok {
auth.GoogleServiceAccount = s
}
if s, ok := authObj["project"].(string); ok {
auth.GoogleProject = s
}
if s, ok := authObj["location"].(string); ok {
auth.GoogleLocation = s
}
return auth
}

Expand Down
Loading
Loading