From 82b05956b1f1534eda0ea103e9339b9a475a1799 Mon Sep 17 00:00:00 2001 From: Pavel Anni Date: Thu, 9 Jul 2026 16:27:00 -0400 Subject: [PATCH 1/2] feat: add spec.serviceAccountName for Workload Identity support Enable cloud Workload Identity (AWS IRSA, GCP WI, Azure WI) by allowing a custom ServiceAccount on the gateway pod. When set, the operator also enables automountServiceAccountToken so the projected SA token is available inside the pod for request-level signing APIs (S3, GCS, Azure Blob) that the proxy's header injection model cannot support. The SA is set only on the gateway Deployment; the proxy is unaffected. When the field is omitted, existing behavior is preserved. Assisted-By: Claude (Anthropic AI) Signed-off-by: Pavel Anni --- CLAUDE.md | 2 +- api/v1alpha1/claw_types.go | 8 + .../bases/claw.sandbox.redhat.com_claws.yaml | 8 + docs/adr/0022-service-account-name.md | 67 ++++++ docs/user-guide.md | 65 ++++++ internal/controller/claw_deployment.go | 34 +++ internal/controller/claw_deployment_test.go | 208 ++++++++++++++++++ .../controller/claw_resource_controller.go | 3 + 8 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0022-service-account-name.md diff --git a/CLAUDE.md b/CLAUDE.md index b6ad2dd7..279db912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Kubernetes operator (Go, Kubebuilder/Operator SDK) that manages OpenClaw instances on OpenShift/Kubernetes. Detailed architecture reference in `docs/architecture.md`. **CRDs** (API group `claw.sandbox.redhat.com/v1alpha1`): -- `Claw` — Main CRD. Spec: `config` (ConfigSpec: raw RawExtension, mergeMode merge/overwrite/seedOnly), `credentials` ([]CredentialSpec), `auth` (AuthSpec: mode token/password, passwordSecretRef, disableDevicePairing). Status: `conditions`, `url`, `gatewayTokenSecretRef` +- `Claw` — Main CRD. Spec: `config` (ConfigSpec: raw RawExtension, mergeMode merge/overwrite/seedOnly), `credentials` ([]CredentialSpec), `auth` (AuthSpec: mode token/password, passwordSecretRef, disableDevicePairing), `serviceAccountName` (string, optional — sets gateway pod SA for Workload Identity). Status: `conditions`, `url`, `gatewayTokenSecretRef` - `ClawDevicePairingRequest` — Device pairing. Spec: `requestID`, `selector` (LabelSelector). Controller matches exactly one pod - `ClawOperatorConfig` — Cluster-admin policy singleton (name `cluster`, in the operator's own namespace). Spec: `allowedConfigModes` ([]ConfigMode, empty = unrestricted). Gates which `mergeMode` values `Claw` CRs may use; see [ADR-0021](docs/adr/0021-seed-only-config-mode.md) diff --git a/api/v1alpha1/claw_types.go b/api/v1alpha1/claw_types.go index efe8a727..0971ec2e 100644 --- a/api/v1alpha1/claw_types.go +++ b/api/v1alpha1/claw_types.go @@ -686,6 +686,14 @@ type ClawSpec struct { // +optional Network *NetworkSpec `json:"network,omitempty"` + // ServiceAccountName sets the Kubernetes ServiceAccount on the gateway + // pod. When set, automountServiceAccountToken is enabled so the SA token + // is projected into the pod for Workload Identity (AWS IRSA, GCP WI, + // Azure WI). When omitted, the default ServiceAccount is used with no + // token mounted. The SA must be pre-created by the cluster admin. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` + // Plugins lists OpenClaw plugins to install via an init container before // the gateway starts. Each entry is a package name (e.g. "@openclaw/matrix"). // The operator runs `openclaw plugins install ` for each entry. diff --git a/config/crd/bases/claw.sandbox.redhat.com_claws.yaml b/config/crd/bases/claw.sandbox.redhat.com_claws.yaml index 58729f6e..0d1dd573 100644 --- a/config/crd/bases/claw.sandbox.redhat.com_claws.yaml +++ b/config/crd/bases/claw.sandbox.redhat.com_claws.yaml @@ -767,6 +767,14 @@ spec: items: type: string type: array + serviceAccountName: + description: |- + ServiceAccountName sets the Kubernetes ServiceAccount on the gateway + pod. When set, automountServiceAccountToken is enabled so the SA token + is projected into the pod for Workload Identity (AWS IRSA, GCP WI, + Azure WI). When omitted, the default ServiceAccount is used with no + token mounted. The SA must be pre-created by the cluster admin. + type: string skills: additionalProperties: type: string diff --git a/docs/adr/0022-service-account-name.md b/docs/adr/0022-service-account-name.md new file mode 100644 index 00000000..136a3fc9 --- /dev/null +++ b/docs/adr/0022-service-account-name.md @@ -0,0 +1,67 @@ +# ADR-0022: Gateway ServiceAccount for Workload Identity + +**Status:** Implemented +**Date:** 2026-07-09 + +## Overview + +Agents need to exchange data with cloud storage services (S3, GCS, Azure +Blob) for backup/restore, document import/export, and dataset access. +The proxy's credential injection model cannot support this because cloud +storage APIs use request-level signing (AWS SigV4, GCP signed requests) +rather than simple header injection — the proxy would need to strip the +client's cryptographic signature and re-sign every request, effectively +reimplementing the full S3/GCS protocol. This is a fundamental mismatch, +not a gap that can be closed with a new injector type. + +Without operator support, the only workaround is static long-lived cloud +credentials configured inside the agent container (e.g., `rclone config` +with `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`). This puts permanent +secrets directly in the agent's environment, violating the operator's +core security principle: credentials should never be visible to the +gateway pod. + +Kubernetes Workload Identity solves this at the platform level: the pod +gets short-lived, auto-rotating credentials via a projected +ServiceAccount token, with no static secrets. All three major cloud +platforms use the same mechanism — annotate a ServiceAccount, assign it +to the pod, and the cloud SDK picks up temporary credentials from the +projected token. + +## Decisions + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| 1 | Surface area | Single optional `spec.serviceAccountName` string field | Minimal change. The SA and its cloud IAM binding are the admin's responsibility — the operator just wires the reference into the pod spec. | +| 2 | Token mounting | Enable `automountServiceAccountToken` only when the field is set | The embedded deployment manifest defaults to `automountServiceAccountToken: false`. Enabling it only on explicit opt-in preserves the current security posture for all existing instances. | +| 3 | Scope | Gateway Deployment only, not the proxy | The proxy handles credential injection via MITM — it has no need for cloud storage access. Only the gateway pod (where the agent runs) needs the projected SA token. | +| 4 | SA existence validation | None — delegate to Kubernetes | If the SA doesn't exist, the pod stays Pending with a clear event message. This matches standard Kubernetes behavior and avoids adding a reconcile-time check that could race with SA creation. | +| 5 | Field placement | Near `spec.network` in `ClawSpec` | Both are pod-level infrastructure concerns. Placing it next to `network` groups related infrastructure fields rather than burying it after operational flags like `idle`. | + +## Security Considerations + +Mounting a SA token gives the agent access to whatever RBAC or cloud IAM +role the SA carries. This is a deliberate, documented trade-off: + +| | Without `serviceAccountName` | With `serviceAccountName` | +|---|---|---| +| **Credential type** | Static long-lived keys | Short-lived auto-rotating tokens | +| **Rotation** | Manual | Automatic (platform-managed) | +| **Blast radius on compromise** | Permanent cloud access | ~1 hour token scoped to one IAM role | +| **Visibility to agent** | Agent sees raw keys | Agent sees only a projected token path | + +The operator does not audit SA permissions — that is the admin's +responsibility. Documentation emphasizes creating narrowly-scoped SAs +with minimal permissions. + +## Implementation Notes + +- `api/v1alpha1/claw_types.go`: `ServiceAccountName` string field added + to `ClawSpec`, placed after `Network`. +- `internal/controller/claw_deployment.go`: + `configureClawDeploymentServiceAccount` sets `serviceAccountName` and + `automountServiceAccountToken: true` on the gateway Deployment's pod + template. No-op when the field is empty. +- `internal/controller/claw_resource_controller.go`: called after + `configureGatewayNoProxy` in the Phase 3 deployment mutation sequence. +- No proxy changes, no new conditions, no migration needed. diff --git a/docs/user-guide.md b/docs/user-guide.md index 4820ac57..909bdff2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1617,6 +1617,71 @@ spec: > **Warning:** If `inClusterBypass` is `true` and an in-cluster MCP server has `credentialRef`, the operator sets a warning condition — credentials cannot be injected when traffic bypasses the proxy. +## Service Account / Workload Identity + +The `spec.serviceAccountName` field assigns a custom Kubernetes ServiceAccount to the gateway pod, enabling cloud Workload Identity for services that use request-level signing (AWS S3, GCS, Azure Blob). The proxy's header injection model cannot support these APIs because they sign every request cryptographically — changing any header after signing invalidates the signature. + +When `serviceAccountName` is set, the operator also enables `automountServiceAccountToken` so the projected SA token is available inside the pod. When omitted, the default behavior is preserved (default SA, no token mounted). + +> **Security:** The SA token gives the agent access to whatever RBAC or cloud IAM role the SA carries. Create narrowly-scoped SAs with minimal permissions. Short-lived auto-rotating Workload Identity tokens are strictly better than static access keys configured inside the container. + +### AWS (EKS / ROSA) — IRSA + +**1. Create a ServiceAccount with the IRSA annotation:** + +```sh +oc create sa claw-s3-access -n $NS +oc annotate sa claw-s3-access -n $NS \ + eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/claw-s3-role +``` + +**2. Apply the Claw CR:** + +```sh +oc apply -n $NS -f - < Date: Thu, 9 Jul 2026 16:47:25 -0400 Subject: [PATCH 2/2] fix: address code review findings for serviceAccountName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Azure WI from supported platforms — its mutating webhook requires a pod-level label the operator doesn't inject yet - Add gateway assertions to proxy-isolation unit test - Add automountServiceAccountToken assertion on proxy integration test - Add update/removal scenario test (change SA, then clear it) - Include deployment name in error message for consistency - Remove shadowed ctx in integration tests - Add comment explaining why assert.Empty fails for SA name - Change ADR status from Implemented to Accepted - Simplify AWS IRSA example (remove unrelated LLM credential) Assisted-By: Claude (Anthropic AI) Signed-off-by: Pavel Anni --- docs/adr/0022-service-account-name.md | 21 ++++-- docs/user-guide.md | 19 +---- internal/controller/claw_deployment.go | 2 +- internal/controller/claw_deployment_test.go | 81 ++++++++++++++++++++- 4 files changed, 96 insertions(+), 27 deletions(-) diff --git a/docs/adr/0022-service-account-name.md b/docs/adr/0022-service-account-name.md index 136a3fc9..5d15ef9d 100644 --- a/docs/adr/0022-service-account-name.md +++ b/docs/adr/0022-service-account-name.md @@ -1,12 +1,12 @@ # ADR-0022: Gateway ServiceAccount for Workload Identity -**Status:** Implemented +**Status:** Accepted **Date:** 2026-07-09 ## Overview -Agents need to exchange data with cloud storage services (S3, GCS, Azure -Blob) for backup/restore, document import/export, and dataset access. +Agents need to exchange data with cloud storage services (S3, GCS) for +backup/restore, document import/export, and dataset access. The proxy's credential injection model cannot support this because cloud storage APIs use request-level signing (AWS SigV4, GCP signed requests) rather than simple header injection — the proxy would need to strip the @@ -23,10 +23,13 @@ gateway pod. Kubernetes Workload Identity solves this at the platform level: the pod gets short-lived, auto-rotating credentials via a projected -ServiceAccount token, with no static secrets. All three major cloud -platforms use the same mechanism — annotate a ServiceAccount, assign it -to the pod, and the cloud SDK picks up temporary credentials from the -projected token. +ServiceAccount token, with no static secrets. AWS IRSA and GCP Workload +Identity work with SA annotations alone — annotate a ServiceAccount, +assign it to the pod, and the cloud SDK picks up temporary credentials +from the projected token. Azure Workload Identity additionally requires +a pod-level label (`azure.workload.identity/use: "true"`) for its +mutating webhook; this is not yet supported and will be added in a +follow-up. ## Decisions @@ -65,3 +68,7 @@ with minimal permissions. - `internal/controller/claw_resource_controller.go`: called after `configureGatewayNoProxy` in the Phase 3 deployment mutation sequence. - No proxy changes, no new conditions, no migration needed. +- **Azure deferred:** Azure Workload Identity requires the operator to + inject `azure.workload.identity/use: "true"` as a pod-template label, + which this change does not implement. AWS IRSA and GCP WI work with + `serviceAccountName` + `automountServiceAccountToken` alone. diff --git a/docs/user-guide.md b/docs/user-guide.md index 909bdff2..051ed1b9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1619,7 +1619,7 @@ spec: ## Service Account / Workload Identity -The `spec.serviceAccountName` field assigns a custom Kubernetes ServiceAccount to the gateway pod, enabling cloud Workload Identity for services that use request-level signing (AWS S3, GCS, Azure Blob). The proxy's header injection model cannot support these APIs because they sign every request cryptographically — changing any header after signing invalidates the signature. +The `spec.serviceAccountName` field assigns a custom Kubernetes ServiceAccount to the gateway pod, enabling cloud Workload Identity for services that use request-level signing (AWS S3, GCS). The proxy's header injection model cannot support these APIs because they sign every request cryptographically — changing any header after signing invalidates the signature. When `serviceAccountName` is set, the operator also enables `automountServiceAccountToken` so the projected SA token is available inside the pod. When omitted, the default behavior is preserved (default SA, no token mounted). @@ -1645,12 +1645,7 @@ metadata: name: instance spec: serviceAccountName: claw-s3-access - credentials: - - name: gemini - provider: google - secretRef: - - name: gemini-api-key - key: api-key + credentials: [] # your existing LLM credentials EOF ``` @@ -1668,15 +1663,7 @@ Then set `serviceAccountName: claw-gcs-access` in the Claw CR. ### Azure (AKS) — Workload Identity -```sh -oc create sa claw-blob-access -n $NS -oc annotate sa claw-blob-access -n $NS \ - azure.workload.identity/client-id=YOUR_CLIENT_ID -oc label sa claw-blob-access -n $NS \ - azure.workload.identity/use=true -``` - -Then set `serviceAccountName: claw-blob-access` in the Claw CR. +> **Not yet supported.** Azure Workload Identity requires an `azure.workload.identity/use: "true"` label on the pod template for its mutating webhook to inject the projected token and `AZURE_*` env vars. The operator does not currently inject this label. AWS IRSA and GCP WI work with `serviceAccountName` alone; Azure support will be added in a future release. ### Scope diff --git a/internal/controller/claw_deployment.go b/internal/controller/claw_deployment.go index cfa1e921..7b6b4f86 100644 --- a/internal/controller/claw_deployment.go +++ b/internal/controller/claw_deployment.go @@ -854,7 +854,7 @@ func configureClawDeploymentServiceAccount( } return nil } - return fmt.Errorf("claw deployment not found in manifests") + return fmt.Errorf("gateway deployment %s not found in manifests", gatewayName) } // inClusterBypassEnabled returns true if spec.network.inClusterBypass is explicitly true. diff --git a/internal/controller/claw_deployment_test.go b/internal/controller/claw_deployment_test.go index 8f10c936..7610c8bf 100644 --- a/internal/controller/claw_deployment_test.go +++ b/internal/controller/claw_deployment_test.go @@ -2032,7 +2032,8 @@ func TestConfigureClawDeploymentServiceAccount(t *testing.T) { err := configureClawDeploymentServiceAccount(objects, instance) assert.Error(t, err) - assert.Contains(t, err.Error(), "claw deployment not found") + assert.Contains(t, err.Error(), "gateway deployment") + assert.Contains(t, err.Error(), "not found in manifests") }) t.Run("should only modify gateway deployment, not proxy", func(t *testing.T) { @@ -2071,6 +2072,18 @@ func TestConfigureClawDeploymentServiceAccount(t *testing.T) { require.NoError(t, configureClawDeploymentServiceAccount(objects, instance)) + gatewaySA, found, err := unstructured.NestedString( + gatewayDep.Object, "spec", "template", "spec", "serviceAccountName") + require.NoError(t, err) + assert.True(t, found, "gateway should have serviceAccountName set") + assert.Equal(t, testServiceAccountName, gatewaySA) + + gatewayAutoMount, found, err := unstructured.NestedBool( + gatewayDep.Object, "spec", "template", "spec", "automountServiceAccountToken") + require.NoError(t, err) + assert.True(t, found, "gateway should have automountServiceAccountToken set") + assert.True(t, gatewayAutoMount, "gateway automountServiceAccountToken should be true") + proxySA, found, _ := unstructured.NestedString( proxyDep.Object, "spec", "template", "spec", "serviceAccountName") assert.False(t, found, "proxy should not have serviceAccountName set") @@ -2083,8 +2096,6 @@ func TestConfigureClawDeploymentServiceAccount(t *testing.T) { } func TestServiceAccountIntegration(t *testing.T) { - ctx := context.Background() - t.Run("should set serviceAccountName on gateway pod when specified", func(t *testing.T) { t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) @@ -2133,6 +2144,7 @@ func TestServiceAccountIntegration(t *testing.T) { }, deployment) == nil }, "gateway deployment should be created") + // The API server defaults serviceAccountName to "default" — assert.Empty would fail. assert.NotEqual(t, "workload-identity-sa", deployment.Spec.Template.Spec.ServiceAccountName, "gateway pod should not have a custom SA when omitted") require.NotNil(t, deployment.Spec.Template.Spec.AutomountServiceAccountToken, @@ -2168,6 +2180,69 @@ func TestServiceAccountIntegration(t *testing.T) { assert.NotEqual(t, "workload-identity-sa", proxyDeploy.Spec.Template.Spec.ServiceAccountName, "proxy pod should not have the gateway's custom SA") + require.NotNil(t, proxyDeploy.Spec.Template.Spec.AutomountServiceAccountToken, + "proxy automountServiceAccountToken should be set") + assert.False(t, *proxyDeploy.Spec.Template.Spec.AutomountServiceAccountToken, + "proxy automountServiceAccountToken should remain false") + }) + + t.Run("should update deployment when serviceAccountName changes", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + secret := createTestAPIKeySecret(aiModelSecret, namespace, aiModelSecretKey, aiModelSecretValue) + require.NoError(t, k8sClient.Create(ctx, secret)) + + instance := &clawv1alpha1.Claw{ + ObjectMeta: metav1.ObjectMeta{Name: testInstanceName, Namespace: namespace}, + Spec: clawv1alpha1.ClawSpec{ + Credentials: testCredentials(), + ServiceAccountName: "first-sa", + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + deployment := &appsv1.Deployment{} + waitFor(t, timeout, interval, func() bool { + return k8sClient.Get(ctx, client.ObjectKey{ + Name: getClawDeploymentName(testInstanceName), Namespace: namespace, + }, deployment) == nil + }, "gateway deployment should be created") + + assert.Equal(t, "first-sa", deployment.Spec.Template.Spec.ServiceAccountName) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: testInstanceName, Namespace: namespace, + }, instance)) + instance.Spec.ServiceAccountName = "second-sa" + require.NoError(t, k8sClient.Update(ctx, instance)) + + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getClawDeploymentName(testInstanceName), Namespace: namespace, + }, deployment)) + assert.Equal(t, "second-sa", deployment.Spec.Template.Spec.ServiceAccountName, + "gateway should use updated SA after reconcile") + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: testInstanceName, Namespace: namespace, + }, instance)) + instance.Spec.ServiceAccountName = "" + require.NoError(t, k8sClient.Update(ctx, instance)) + + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{ + Name: getClawDeploymentName(testInstanceName), Namespace: namespace, + }, deployment)) + assert.NotEqual(t, "second-sa", deployment.Spec.Template.Spec.ServiceAccountName, + "gateway should revert SA after clearing the field") + require.NotNil(t, deployment.Spec.Template.Spec.AutomountServiceAccountToken) + assert.False(t, *deployment.Spec.Template.Spec.AutomountServiceAccountToken, + "automountServiceAccountToken should revert to false") }) }