From 58054e90346e4fed74a00f4a1fbd9ea99015519a Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Mon, 13 Jul 2026 10:20:43 -0400 Subject: [PATCH 1/4] docs(values): clarify spiffe.enabled vs operatorAuth.enabled, remove jwtAudience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spiffe.enabled — master switch indicating SPIRE is present cluster-wide. spiffe.operatorAuth.enabled — narrower flag for operator-to-Keycloak auth. Both must be true to enable the spiffe-helper sidecar. jwtAudience was always going to equal keycloak.publicUrl/realms/. Remove the user-facing field; the template now derives it and fails at helm install time if keycloak.publicUrl is not set. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- charts/kagenti-operator/values.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index acdde623..e941c3c8 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -190,23 +190,23 @@ sigstore: configMapKey: "trusted-root.json" # [SPIFFE OPERATOR AUTH]: Operator authentication to Keycloak using JWT-SVID -# When enabled, the operator uses its SPIFFE identity (JWT-SVID) to authenticate -# to Keycloak instead of admin credentials. Requires Keycloak SPIFFE IdP configured. +# spiffe.enabled — master switch indicating that SPIRE is present in the cluster +# and SPIFFE workload identities are available. Set to true whenever --with-spire +# is used. Features that consume SPIFFE identities (e.g. verified-fetch, operatorAuth) +# each have their own sub-flag; this top-level switch guards them all. +# +# spiffe.operatorAuth — controls how the operator authenticates to the Keycloak +# Admin API when registering agent OAuth clients. When disabled (default), the +# operator uses admin credentials from keycloak-admin-secret. When enabled, the +# operator uses its own SPIFFE JWT-SVID, eliminating the need for that secret. +# This is independent of how agent/tool workloads authenticate (see authBridge +# clientAuthType in the kagenti chart). spiffe: enabled: false operatorAuth: enabled: false - # JWT audience must match Keycloak's realm issuer URL exactly. - # If not set, defaults to: {{ .Values.keycloak.publicUrl }}/realms/{{ .Values.keycloak.realm }} - # Check Keycloak's .well-known/openid-configuration for the correct issuer value. - jwtAudience: "" # Path to JWT-SVID file written by spiffe-helper sidecar jwtSVIDPath: "/opt/jwt_svid.token" - spiffeHelper: - image: - repository: ghcr.io/kagenti/kagenti-extensions/spiffe-helper - tag: v0.6.0-alpha.4 - pullPolicy: IfNotPresent # Feature gates — highest-priority layer in the injection precedence chain. # Set globalEnabled to false to disable ALL sidecar injection (kill switch). From 7d21412fa6ea3bf1b960a218075643bc7e2ecc16 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Mon, 13 Jul 2026 10:20:57 -0400 Subject: [PATCH 2/4] fix(charts): remove spiffeHelper.image values, hardcode spiffe-helper:latest The spiffeHelper.image.* values block was introduced to pin the image tag but the standalone spiffe-helper image is not published by the kagenti-extensions automated pipeline. Only :latest exists from a historical publish. Remove the values block and hardcode :latest. Issue #477 tracks replacing the sidecar with a direct go-spiffe SDK call. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../templates/manager/configmap-spiffe-helper.yaml | 14 +++++++++++++- .../templates/manager/manager.yaml | 4 ++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml index 75cb7079..075b3b16 100644 --- a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml +++ b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml @@ -17,8 +17,20 @@ data: cert_dir = "" svid_file_name = "" svid_bundle_file_name = "" + {{- /* + jwt_audience must equal the Keycloak realm's issuer URL — the string Keycloak + advertises in its /.well-known/openid-configuration. It is always derived from + keycloak.publicUrl (the external URL Keycloak was configured with). The internal + k8s service address must NOT be used because Keycloak's FederatedJWTClientValidator + checks the aud claim via string equality against its own issuer, which is always + the external URL. + */ -}} + {{- $audience := printf "%s/realms/%s" .Values.keycloak.publicUrl .Values.keycloak.realm -}} + {{- if not (hasPrefix "http" $audience) -}} + {{- fail "keycloak.publicUrl must be set to the external Keycloak URL (e.g. http://keycloak.example.com) when spiffe.operatorAuth.enabled=true" -}} + {{- end -}} jwt_svids = [{ - jwt_audience = "{{ required "spiffe.operatorAuth.jwtAudience must be set when spiffe.operatorAuth.enabled=true (e.g. http://keycloak.example.com/realms/kagenti)" .Values.spiffe.operatorAuth.jwtAudience }}" + jwt_audience = "{{ $audience }}" jwt_svid_file_name = "/opt/jwt_svid.token" }] {{- end }} diff --git a/charts/kagenti-operator/templates/manager/manager.yaml b/charts/kagenti-operator/templates/manager/manager.yaml index 5a9fc57b..d7fa042f 100644 --- a/charts/kagenti-operator/templates/manager/manager.yaml +++ b/charts/kagenti-operator/templates/manager/manager.yaml @@ -193,8 +193,8 @@ spec: {{- end }} {{- if and .Values.spiffe .Values.spiffe.enabled .Values.spiffe.operatorAuth .Values.spiffe.operatorAuth.enabled }} - name: spiffe-helper - image: {{ .Values.spiffe.operatorAuth.spiffeHelper.image.repository }}:{{ .Values.spiffe.operatorAuth.spiffeHelper.image.tag }} - imagePullPolicy: {{ .Values.spiffe.operatorAuth.spiffeHelper.image.pullPolicy | default "IfNotPresent" }} + image: ghcr.io/kagenti/kagenti-extensions/spiffe-helper:latest + imagePullPolicy: IfNotPresent args: - "-config" - "/etc/spiffe-helper/config.hcl" From a820271f743a64eb50394886eb00320c60b16897 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Mon, 13 Jul 2026 10:21:13 -0400 Subject: [PATCH 3/4] fix(controller): skip credential secret and template annotation in federated-jwt mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In federated-jwt mode, AuthBridge reads JWT-SVIDs from the SPIFFE workload API socket directly — no credential Secret is created or needed. Two fixes: 1. Skip ensureClientCredentialsSecret() when CLIENT_AUTH_TYPE=federated-jwt. The Secret would contain a valid Keycloak client secret that Keycloak correctly rejects (it enforces federated-jwt auth type), but creating it is a hygiene issue and a minor defense-in-depth gap. 2. Skip patchTemplate() in the same case. patchTemplate injects the AnnotationKeycloakClientSecretName onto the Deployment pod template, which the webhook uses to mount the credential Secret. Without the Secret, pods get stuck in Init. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../clientregistration_controller.go | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index 9efcb04a..15ad4e44 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -370,15 +370,28 @@ func (r *ClientRegistrationReconciler) reconcileOne( "clientId", clientID) } + // In federated-jwt mode, agents authenticate using their SPIFFE identity directly. + // No credential secret is needed — AuthBridge reads JWT-SVIDs from the workload + // API socket, not from a mounted secret. Skipping secret creation avoids leaving + // unused Keycloak client secrets in agent namespaces. secretName := keycloakClientCredentialsSecretName(ns, workloadName) - if err := r.ensureClientCredentialsSecret(ctx, owner, secretName, clientID, clientSecret); err != nil { - logger.Error(err, "ensure client credentials secret") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil - } - - if err := patchTemplate(ctx); err != nil { - logger.Error(err, "patch workload pod template") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + if authType != "federated-jwt" { + if err := r.ensureClientCredentialsSecret(ctx, owner, secretName, clientID, clientSecret); err != nil { + logger.Error(err, "ensure client credentials secret") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + // Annotate the pod template with the secret name so the webhook mounts it. + if err := patchTemplate(ctx); err != nil { + logger.Error(err, "patch workload pod template") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + } else { + // In federated-jwt mode, AuthBridge reads JWT-SVIDs directly from the SPIFFE + // workload API socket — no credential secret is needed or created. Skip both the + // secret and the pod template annotation so the webhook does not try to mount a + // non-existent secret. + logger.V(1).Info("skipping credential secret and template patch (federated-jwt — AuthBridge uses SPIFFE identity directly)", + "workload", workloadName, "namespace", ns) } logger.Info("operator client registration applied", From 0d25ce8c2778a19d5f6c51159e5f38c554b08755 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Mon, 13 Jul 2026 10:21:27 -0400 Subject: [PATCH 4/4] fix(webhook): skip credential annotation pre-population in federated-jwt mode The webhook pre-populates AnnotationKeycloakClientSecretName so pods can mount the credential Secret before the controller reconciles. In federated-jwt mode no Secret is created, so injecting this annotation leaves pods stuck in Init waiting for a Secret that never appears. Fix: read CLIENT_AUTH_TYPE from authbridge-config before pre-populating the annotation and skip it when federated-jwt is configured. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../webhook/v1alpha1/authbridge_webhook.go | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go index c6cd01fd..9f186f64 100644 --- a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go +++ b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go @@ -96,16 +96,23 @@ func (w *AuthBridgeWebhook) Handle(ctx context.Context, req admission.Request) a // kubelet will wait for the Secret to appear and mount it — no pod restart required. // Without this, the first pod comes up with an empty /shared/ and envoy returns 503 // "identity not yet configured (credentials pending)" until the user deletes the pod. + // + // Skip in federated-jwt mode: AuthBridge reads JWT-SVIDs from the SPIFFE workload API + // socket directly and no credential Secret is created or needed. Injecting the volume + // mount for a non-existent Secret would leave pods stuck in Init. if pod.Annotations[injector.AnnotationKeycloakClientSecretName] == "" && clientreg.WorkloadWantsOperatorClientReg(pod.Labels, w.Mutator.GetFeatureGates().InjectTools) { - if pod.Annotations == nil { - pod.Annotations = map[string]string{} + nsConfig, _ := injector.ReadNamespaceConfig(ctx, w.Mutator.APIReader, req.Namespace) + if nsConfig == nil || nsConfig.ClientAuthType != injector.ClientAuthTypeFederatedJWT { + if pod.Annotations == nil { + pod.Annotations = map[string]string{} + } + pod.Annotations[injector.AnnotationKeycloakClientSecretName] = + clientreg.KeycloakClientCredentialsSecretName(req.Namespace, resourceName) + authbridgelog.Info("pre-populated Keycloak client credentials annotation", + "namespace", req.Namespace, "name", resourceName, + "secret", pod.Annotations[injector.AnnotationKeycloakClientSecretName]) } - pod.Annotations[injector.AnnotationKeycloakClientSecretName] = - clientreg.KeycloakClientCredentialsSecretName(req.Namespace, resourceName) - authbridgelog.Info("pre-populated Keycloak client credentials annotation", - "namespace", req.Namespace, "name", resourceName, - "secret", pod.Annotations[injector.AnnotationKeycloakClientSecretName]) } // Check if already injected (idempotency / reinvocation)