diff --git a/CLAUDE.md b/CLAUDE.md index b6ad2dd..279db91 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 efe8a72..0971ec2 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 58729f6..0d1dd57 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 0000000..5d15ef9 --- /dev/null +++ b/docs/adr/0022-service-account-name.md @@ -0,0 +1,74 @@ +# ADR-0022: Gateway ServiceAccount for Workload Identity + +**Status:** Accepted +**Date:** 2026-07-09 + +## Overview + +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 +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. 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 + +| # | 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. +- **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 4820ac5..051ed1b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1617,6 +1617,58 @@ 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). 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 - < **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 + +The SA is set only on the gateway Deployment (where the agent runs), not on the proxy Deployment. The operator does not create, validate, or manage the SA itself — that is the cluster admin's responsibility. If the SA does not exist, the pod stays Pending with a standard Kubernetes event message. + ## Plugins The operator supports declarative plugin installation. List plugins in `spec.plugins` and the operator runs an init container that installs them on the PVC before the gateway starts. diff --git a/internal/controller/claw_deployment.go b/internal/controller/claw_deployment.go index c54a716..7b6b4f8 100644 --- a/internal/controller/claw_deployment.go +++ b/internal/controller/claw_deployment.go @@ -823,6 +823,40 @@ func appendNoProxySuffix(container map[string]any) { _ = unstructured.SetNestedSlice(container, envVars, "env") } +// configureClawDeploymentServiceAccount sets serviceAccountName and +// automountServiceAccountToken on the gateway Deployment's pod template +// when spec.serviceAccountName is set. No-op when the field is empty. +func configureClawDeploymentServiceAccount( + objects []*unstructured.Unstructured, + instance *clawv1alpha1.Claw, +) error { + if instance.Spec.ServiceAccountName == "" { + return nil + } + + gatewayName := getClawDeploymentName(instance.Name) + for _, obj := range objects { + if obj.GetKind() != DeploymentKind || obj.GetName() != gatewayName { + continue + } + if err := unstructured.SetNestedField( + obj.Object, + instance.Spec.ServiceAccountName, + "spec", "template", "spec", "serviceAccountName", + ); err != nil { + return fmt.Errorf("failed to set serviceAccountName: %w", err) + } + if err := unstructured.SetNestedField( + obj.Object, true, + "spec", "template", "spec", "automountServiceAccountToken", + ); err != nil { + return fmt.Errorf("failed to set automountServiceAccountToken: %w", err) + } + return nil + } + return fmt.Errorf("gateway deployment %s not found in manifests", gatewayName) +} + // inClusterBypassEnabled returns true if spec.network.inClusterBypass is explicitly true. func inClusterBypassEnabled(instance *clawv1alpha1.Claw) bool { return instance.Spec.Network != nil && diff --git a/internal/controller/claw_deployment_test.go b/internal/controller/claw_deployment_test.go index ff1c436..7610c8b 100644 --- a/internal/controller/claw_deployment_test.go +++ b/internal/controller/claw_deployment_test.go @@ -1963,6 +1963,289 @@ func TestEnableServiceLinks(t *testing.T) { }) } +// --- Service account tests --- + +const testServiceAccountName = "my-sa" + +func TestConfigureClawDeploymentServiceAccount(t *testing.T) { + makeDeployment := func() []*unstructured.Unstructured { + dep := &unstructured.Unstructured{} + dep.SetKind(DeploymentKind) + dep.SetName(getClawDeploymentName(testInstanceName)) + dep.Object["spec"] = map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "automountServiceAccountToken": false, + "containers": []any{ + map[string]any{ + "name": ClawGatewayContainerName, + }, + }, + }, + }, + } + return []*unstructured.Unstructured{dep} + } + + t.Run("should set serviceAccountName and enable token mounting", func(t *testing.T) { + objects := makeDeployment() + instance := &clawv1alpha1.Claw{} + instance.Name = testInstanceName + instance.Spec.ServiceAccountName = "my-cloud-sa" + + require.NoError(t, configureClawDeploymentServiceAccount(objects, instance)) + + saName, found, err := unstructured.NestedString( + objects[0].Object, "spec", "template", "spec", "serviceAccountName") + require.NoError(t, err) + assert.True(t, found, "serviceAccountName should be set") + assert.Equal(t, "my-cloud-sa", saName) + + autoMount, found, err := unstructured.NestedBool( + objects[0].Object, "spec", "template", "spec", "automountServiceAccountToken") + require.NoError(t, err) + assert.True(t, found, "automountServiceAccountToken should be set") + assert.True(t, autoMount, "automountServiceAccountToken should be true") + }) + + t.Run("should be no-op when serviceAccountName is empty", func(t *testing.T) { + objects := makeDeployment() + instance := &clawv1alpha1.Claw{} + instance.Name = testInstanceName + + require.NoError(t, configureClawDeploymentServiceAccount(objects, instance)) + + _, found, _ := unstructured.NestedString( + objects[0].Object, "spec", "template", "spec", "serviceAccountName") + assert.False(t, found, "serviceAccountName should not be set") + + autoMount, _, _ := unstructured.NestedBool( + objects[0].Object, "spec", "template", "spec", "automountServiceAccountToken") + assert.False(t, autoMount, "automountServiceAccountToken should remain false") + }) + + t.Run("should return error when deployment is missing", func(t *testing.T) { + objects := []*unstructured.Unstructured{} + instance := &clawv1alpha1.Claw{} + instance.Name = testInstanceName + instance.Spec.ServiceAccountName = testServiceAccountName + + err := configureClawDeploymentServiceAccount(objects, instance) + assert.Error(t, err) + 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) { + gatewayDep := &unstructured.Unstructured{} + gatewayDep.SetKind(DeploymentKind) + gatewayDep.SetName(getClawDeploymentName(testInstanceName)) + gatewayDep.Object["spec"] = map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "automountServiceAccountToken": false, + "containers": []any{ + map[string]any{"name": ClawGatewayContainerName}, + }, + }, + }, + } + + proxyDep := &unstructured.Unstructured{} + proxyDep.SetKind(DeploymentKind) + proxyDep.SetName(getProxyDeploymentName(testInstanceName)) + proxyDep.Object["spec"] = map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "automountServiceAccountToken": false, + "containers": []any{ + map[string]any{"name": "proxy"}, + }, + }, + }, + } + + objects := []*unstructured.Unstructured{gatewayDep, proxyDep} + instance := &clawv1alpha1.Claw{} + instance.Name = testInstanceName + instance.Spec.ServiceAccountName = testServiceAccountName + + 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") + assert.Empty(t, proxySA) + + proxyAutoMount, _, _ := unstructured.NestedBool( + proxyDep.Object, "spec", "template", "spec", "automountServiceAccountToken") + assert.False(t, proxyAutoMount, "proxy automountServiceAccountToken should remain false") + }) +} + +func TestServiceAccountIntegration(t *testing.T) { + t.Run("should set serviceAccountName on gateway pod when specified", 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: "workload-identity-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, "workload-identity-sa", deployment.Spec.Template.Spec.ServiceAccountName, + "gateway pod should use the custom SA") + require.NotNil(t, deployment.Spec.Template.Spec.AutomountServiceAccountToken, + "automountServiceAccountToken should be set") + assert.True(t, *deployment.Spec.Template.Spec.AutomountServiceAccountToken, + "automountServiceAccountToken should be true when SA is specified") + }) + + t.Run("should not set serviceAccountName when omitted", func(t *testing.T) { + t.Cleanup(func() { deleteAndWaitAllResources(t, namespace) }) + + createClawInstance(t, ctx, testInstanceName, namespace) + + 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") + + // 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, + "automountServiceAccountToken should be set") + assert.False(t, *deployment.Spec.Template.Spec.AutomountServiceAccountToken, + "automountServiceAccountToken should be false when SA is not specified") + }) + + t.Run("proxy pod should not be affected by serviceAccountName", 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: "workload-identity-sa", + }, + } + require.NoError(t, k8sClient.Create(ctx, instance)) + + reconciler := createClawReconciler() + reconcileClaw(t, ctx, reconciler, testInstanceName, namespace) + + proxyDeploy := &appsv1.Deployment{} + waitFor(t, timeout, interval, func() bool { + return k8sClient.Get(ctx, client.ObjectKey{ + Name: getProxyDeploymentName(testInstanceName), Namespace: namespace, + }, proxyDeploy) == nil + }, "proxy deployment should be created") + + 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") + }) +} + // --- Image field tests --- func TestOpenClawImage(t *testing.T) { diff --git a/internal/controller/claw_resource_controller.go b/internal/controller/claw_resource_controller.go index 9a1254d..caf446c 100644 --- a/internal/controller/claw_resource_controller.go +++ b/internal/controller/claw_resource_controller.go @@ -943,6 +943,9 @@ func (r *ClawResourceReconciler) configureDeployments( if err := configureGatewayNoProxy(objects, instance); err != nil { return fmt.Errorf("failed to configure gateway NO_PROXY: %w", err) } + if err := configureClawDeploymentServiceAccount(objects, instance); err != nil { + return fmt.Errorf("failed to configure service account: %w", err) + } if err := configureImagePullPolicy(objects, r.ImagePullPolicy); err != nil { return fmt.Errorf("failed to configure image pull policy: %w", err) }