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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions api/v1alpha1/claw_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>` for each entry.
Expand Down
8 changes: 8 additions & 0 deletions config/crd/bases/claw.sandbox.redhat.com_claws.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions docs/adr/0022-service-account-name.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <<EOF
apiVersion: claw.sandbox.redhat.com/v1alpha1
kind: Claw
metadata:
name: instance
spec:
serviceAccountName: claw-s3-access
credentials: [] # your existing LLM credentials
EOF
```

The agent can now use `rclone` with `env_auth=true` or the AWS SDK — the projected SA token is exchanged for temporary STS credentials automatically.

### GCP (GKE) — Workload Identity

```sh
oc create sa claw-gcs-access -n $NS
oc annotate sa claw-gcs-access -n $NS \
iam.gke.io/gcp-service-account=claw-gcs@YOUR_PROJECT.iam.gserviceaccount.com
```

Then set `serviceAccountName: claw-gcs-access` in the Claw CR.

### Azure (AKS) — Workload Identity

> **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.
Expand Down
34 changes: 34 additions & 0 deletions internal/controller/claw_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Loading
Loading