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
111 changes: 12 additions & 99 deletions docs/guides/admin/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,31 +36,14 @@ The `--gcp-region` flag is required when `--gcp-project` is set. Use `global` fo

## 1. Set up GCP authentication

Fullsend supports two methods for authenticating to Vertex AI. **Workload Identity Federation (WIF) is recommended** — it eliminates long-lived credentials entirely.
Fullsend uses [Workload Identity Federation (WIF)](https://cloud.google.com/iam/docs/workload-identity-federation) to authenticate GitHub Actions to Vertex AI. WIF eliminates long-lived credentials — GitHub Actions exchange short-lived OIDC tokens for GCP access tokens. See the [google-github-actions/auth documentation](https://github.com/google-github-actions/auth#direct-workload-identity-federation) for background on direct WIF.

### Option A: Workload Identity Federation (recommended)

WIF lets GitHub Actions exchange short-lived OIDC tokens for GCP access tokens. No service account keys are stored.

**1a. Create a service account**
**1a. Create a Workload Identity Pool and OIDC Provider**

```bash
export GCP_PROJECT="<gcp-project>"
export ORG_NAME="<org-name>"

gcloud iam service-accounts create fullsend-agent \
--display-name="Fullsend agent inference" \
--project="$GCP_PROJECT"

gcloud projects add-iam-policy-binding "$GCP_PROJECT" \
--member="serviceAccount:fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" \
--role="roles/aiplatform.user" \
--condition=None
```

**1b. Create a Workload Identity Pool and OIDC Provider**

```bash
gcloud iam workload-identity-pools create github-actions \
--location=global \
--display-name="GitHub Actions" \
Expand All @@ -71,65 +54,28 @@ gcloud iam workload-identity-pools providers create-oidc github \
--workload-identity-pool=github-actions \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository_owner=assertion.repository_owner,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository_owner == '$ORG_NAME'" \
--attribute-condition="assertion.repository == '$ORG_NAME/.fullsend'" \
--project="$GCP_PROJECT"
```

The `attribute-condition` restricts which GitHub Actions workflows can exchange OIDC tokens for GCP credentials.

- **Org-wide** (`repository_owner`): any repo in the org can authenticate. Simpler to maintain but means a compromised or misconfigured workflow in *any* repo could obtain Vertex AI credentials.
- **Repo-scoped** (`repository`): only the `.fullsend` repo can authenticate. Limits blast radius — recommended for orgs where not all repos are equally trusted.

For repo-scoped access, replace the `attribute-condition` above with:

```bash
--attribute-condition="assertion.repository == '$ORG_NAME/.fullsend'"
```

If you choose repo-scoped access, also update the `--member` in step 1c to match:

```bash
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository/$ORG_NAME/.fullsend"
```
The `attribute-condition` restricts which GitHub Actions workflows can exchange OIDC tokens for GCP credentials. Scoping to `$ORG_NAME/.fullsend` ensures only the `.fullsend` config repo can authenticate — workflows in other repos cannot obtain Vertex AI credentials.

**1c. Grant the service account impersonation permission**
**1b. Grant Vertex AI access to the WIF principal**

```bash
export PROJECT_NUMBER=$(gcloud projects describe "$GCP_PROJECT" --format='value(projectNumber)')
export WIF_PRINCIPAL="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository/$ORG_NAME/.fullsend"

gcloud iam service-accounts add-iam-policy-binding \
"fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository_owner/$ORG_NAME" \
--project="$GCP_PROJECT"
gcloud projects add-iam-policy-binding "$GCP_PROJECT" \
--role="roles/aiplatform.user" \
--member="$WIF_PRINCIPAL" \
--condition=None
```

**1d. Note the WIF provider resource name**
**1c. Note the WIF provider resource name**

```bash
export WIF_PROVIDER="projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/providers/github"
export WIF_SA_EMAIL="fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com"
```

### Option B: Service account key (legacy)

Create a service account with the `Vertex AI User` role and download its key:

```bash
export GCP_PROJECT="<gcp-project>"
export ORG_NAME="<org-name>"

gcloud iam service-accounts create "$ORG_NAME" \
--display-name="Fullsend for $ORG_NAME" \
--project="$GCP_PROJECT"

gcloud projects add-iam-policy-binding "$GCP_PROJECT" \
--member="serviceAccount:$ORG_NAME@$GCP_PROJECT.iam.gserviceaccount.com" \
--role="roles/aiplatform.user" \
--condition=None

gcloud iam service-accounts keys create sa-key.json \
--iam-account="$ORG_NAME@$GCP_PROJECT.iam.gserviceaccount.com"
```

## 2. Run the installer
Expand All @@ -151,11 +97,10 @@ fullsend admin install "$ORG_NAME" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
--gcp-wif-sa-email "$WIF_SA_EMAIL" \
--mint-project "$GCP_PROJECT"
```

`--mint-project` specifies the GCP project where the OIDC token mint Cloud Function is deployed. It can be the same project as `--gcp-project` or a separate project. The installer automatically provisions a Cloud Function, WIF pool (`fullsend-pool`), WIF provider (`github-oidc`), and Secret Manager secrets in the mint project. A service account (`fullsend-dispatch`) is also created as the Cloud Function's runtime identity to access Secret Manager — this is internal infrastructure and does not require any admin setup.
`--mint-project` specifies the GCP project where the OIDC token mint Cloud Function is deployed. It can be the same project as `--gcp-project` or a separate project. The installer automatically provisions a Cloud Function, WIF pool (`fullsend-pool`), WIF provider (`github-oidc`), and Secret Manager secrets in the mint project. A service account (`fullsend-mint`) is also created as the Cloud Function's runtime identity to access Secret Manager — this is internal infrastructure and does not require any admin setup.

Additional mint flags:

Expand All @@ -180,7 +125,6 @@ fullsend admin install "$FIRST_ORG" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
--gcp-wif-sa-email "$WIF_SA_EMAIL" \
--mint-project "$GCP_PROJECT" \
--public
```
Expand All @@ -194,44 +138,13 @@ fullsend admin install "$ADDITIONAL_ORG" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
--gcp-wif-sa-email "$WIF_SA_EMAIL" \
--mint-url "$MINT_URL"
```

`--mint-url` skips Cloud Function deployment and stores PEMs in the existing mint's GCP project. PEMs use org-scoped naming (`fullsend-{org}--{role}-app-pem`), so each org's secrets are stored independently. For public apps (shared across orgs), the provisioner stores the same PEM under each org's scoped key.

> **Note:** Multi-org with `--public` requires all orgs to share the same GitHub Apps. Private apps (the default) are single-org only.

**With SA key (legacy):**

```bash
fullsend admin install "$ORG_NAME" \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-credentials-file sa-key.json \
--mint-project "$GCP_PROJECT"
rm sa-key.json
```

### Migrating from SA key to WIF

If you already have fullsend installed with a service account key:

1. Create the WIF resources (steps 1a–1d in Option A above)
2. Re-run the installer with WIF flags (the installer updates secrets in-place):
```bash
fullsend admin install "$ORG_NAME" \
--skip-app-setup \
--gcp-project "$GCP_PROJECT" \
--gcp-region global \
--gcp-wif-provider "$WIF_PROVIDER" \
--gcp-wif-sa-email "$WIF_SA_EMAIL" \
--mint-project "$GCP_PROJECT"
```
3. Verify a workflow run succeeds with WIF auth (check for "Authenticated using Workload Identity Federation" in the auth step output)
4. Delete the old SA key: `gcloud iam service-accounts keys delete <KEY_ID> --iam-account=...`
5. Remove the `FULLSEND_GCP_SA_KEY_JSON` secret from the `.fullsend` repo settings once the scaffolded agent workflows have been updated to use WIF (re-running `fullsend admin install` with `--skip-app-setup` updates the workflows)

## 3. Merge enrollment PRs

If you chose to enroll repositories during install, the installer dispatches a workflow that creates an enrollment PR in each enrolled repo. These PRs add a shim workflow (`.github/workflows/fullsend.yaml`) that wires events to the agent pipeline.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,13 @@ All of the following are **repository-level** Actions secrets and variables on *
|----------|----------------------------------------|-------|-------|
| Secret | `FULLSEND_<ROLE>_APP_PRIVATE_KEY` | PEM text of the GitHub App private key | secrets |
| Variable | `FULLSEND_<ROLE>_CLIENT_ID` | GitHub App Client ID (e.g. `Iv23_...`), per [GitHub recommendation](https://github.blog/changelog/2024-05-01-github-apps-can-now-use-the-client-id-to-fetch-installation-tokens/) | secrets |
| Secret | `FULLSEND_GCP_SA_KEY_JSON` | GCP service account key JSON (SA key mode only) | inference |
| Secret | `FULLSEND_GCP_WIF_PROVIDER` | Full WIF provider resource name (WIF mode only) | inference |
| Secret | `FULLSEND_GCP_WIF_SA_EMAIL` | Service account email for WIF impersonation (WIF mode only) | inference |
| Secret | `FULLSEND_GCP_WIF_PROVIDER` | Full WIF provider resource name | inference |
| Secret | `FULLSEND_GCP_PROJECT_ID` | GCP project identifier (when inference provider is `vertex`) | inference |
| Variable | `FULLSEND_GCP_REGION` | GCP region for Vertex AI (e.g. `us-east5`) | inference |

- `<ROLE>` is the agent role in **ASCII uppercase** (e.g. `FULLSEND_TRIAGE_APP_PRIVATE_KEY`).
- For each role processed in install, if PEM is non-empty, the implementation **must** create/update the secret; if PEM is empty (reuse path), the implementation **must** skip writing that role’s secret. The Client ID variable is **always** written (even on reuse) to ensure it stays current.
- Inference secrets are only created when an inference provider is configured in `config.yaml` (see [ADR 0011](../adr-0011-org-config-yaml/SPEC.md)). When `inference.provider` is `vertex`, the implementation **must** store `FULLSEND_GCP_PROJECT_ID` and either the SA key secret (`FULLSEND_GCP_SA_KEY_JSON`) or the WIF secrets (`FULLSEND_GCP_WIF_PROVIDER` and `FULLSEND_GCP_WIF_SA_EMAIL`). The two auth modes are mutually exclusive — WIF secrets and the SA key secret **must not** coexist. The WIF provider name and SA email are stored as secrets (not variables) so their values are masked in GitHub Actions logs.
- Inference secrets are only created when an inference provider is configured in `config.yaml` (see [ADR 0011](../adr-0011-org-config-yaml/SPEC.md)). When `inference.provider` is `vertex`, the implementation **must** store `FULLSEND_GCP_PROJECT_ID` and `FULLSEND_GCP_WIF_PROVIDER`. The WIF provider name is stored as a secret (not a variable) so its value is masked in GitHub Actions logs.

## 6. Analyze / health semantics for the secrets layer

Expand Down
1 change: 0 additions & 1 deletion docs/superpowers/plans/2026-05-04-retro-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,6 @@ jobs:
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }}

- name: Authenticate to Google Cloud (SA key)
if: vars.FULLSEND_GCP_AUTH_MODE != 'wif'
Expand Down
44 changes: 12 additions & 32 deletions e2e/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func TestAdminInstallUninstall(t *testing.T) {
// =========================================
// Phase 2.5: Triage dispatch smoke test
// =========================================
if os.Getenv("E2E_HALFSEND_VERTEX_KEY") != "" {
if os.Getenv("E2E_HALFSEND_WIF_PROVIDER") != "" {
t.Log("=== Phase 2.5: Triage Dispatch Smoke Test ===")
vendorBinaryForE2E(t, env)
runTriageDispatchSmokeTest(t, env)
Expand Down Expand Up @@ -271,29 +271,27 @@ func runFullInstall(t *testing.T, env *e2eEnv) ([]layers.AgentCredentials, *conf
agents[i] = ac.AgentEntry
}

// Build inference provider if vertex key is available (mode 3).
// Build inference provider if WIF provider is available.
var inferenceProvider inference.Provider
var inferenceProviderName string
if vertexKey := os.Getenv("E2E_HALFSEND_VERTEX_KEY"); vertexKey != "" {
if wifProvider := os.Getenv("E2E_HALFSEND_WIF_PROVIDER"); wifProvider != "" {
gcpProjectID := os.Getenv("E2E_GCP_PROJECT_ID")
if gcpProjectID == "" {
// Try to extract project_id from the key JSON.
gcpProjectID = extractProjectID(t, vertexKey)
t.Fatal("E2E_GCP_PROJECT_ID is required when E2E_HALFSEND_WIF_PROVIDER is set")
}
gcpRegion := os.Getenv("E2E_GCP_REGION")
if gcpRegion == "" {
gcpRegion = "global"
}
inferenceProvider = vertex.New(vertex.Config{
ProjectID: gcpProjectID,
Region: gcpRegion,
CredentialJSON: []byte(vertexKey),
}, nil)
// Region is stored as a variable, not a secret.
ProjectID: gcpProjectID,
Region: gcpRegion,
WIFProvider: wifProvider,
})
inferenceProviderName = "vertex"
t.Logf("Inference provider: vertex (project: %s)", gcpProjectID)
} else {
t.Log("E2E_HALFSEND_VERTEX_KEY not set, skipping inference layer")
t.Log("E2E_HALFSEND_WIF_PROVIDER not set, skipping inference layer")
}

orgCfg := config.NewOrgConfig(repoNames, enabledRepos, defaultRoles, agents, inferenceProviderName)
Expand Down Expand Up @@ -439,9 +437,9 @@ func verifyInstalled(t *testing.T, env *e2eEnv, orgCfg *config.OrgConfig, enable
assert.True(t, exists, "variable %s should exist", varName)
}

// Inference secrets exist if vertex key was provided.
if os.Getenv("E2E_HALFSEND_VERTEX_KEY") != "" {
for _, secretName := range []string{"FULLSEND_GCP_SA_KEY_JSON", "FULLSEND_GCP_PROJECT_ID"} {
// Inference secrets exist if WIF provider was configured.
if os.Getenv("E2E_HALFSEND_WIF_PROVIDER") != "" {
for _, secretName := range []string{"FULLSEND_GCP_WIF_PROVIDER", "FULLSEND_GCP_PROJECT_ID"} {
exists, secErr := env.client.RepoSecretExists(ctx, testOrg, forge.ConfigRepoName, secretName)
assert.NoError(t, secErr, "checking inference secret %s", secretName)
assert.True(t, exists, "inference secret %s should exist", secretName)
Expand Down Expand Up @@ -858,21 +856,3 @@ func hasPrivateRepos(repos []forge.Repository) bool {
}
return false
}

// extractProjectID attempts to extract project_id from a GCP service account
// key JSON string. Falls back to "unknown" if parsing fails.
func extractProjectID(t *testing.T, keyJSON string) string {
t.Helper()
var key struct {
ProjectID string `json:"project_id"`
}
if err := json.Unmarshal([]byte(keyJSON), &key); err != nil {
t.Logf("warning: could not parse project_id from vertex key: %v", err)
return "unknown"
}
if key.ProjectID == "" {
t.Log("warning: vertex key has empty project_id")
return "unknown"
}
return key.ProjectID
}
Loading
Loading