From a6658af4351368f36029eca428709df7c0b010e9 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Wed, 15 Jul 2026 10:25:27 -0400 Subject: [PATCH 1/2] fix(controller,webhook): write client-id.txt in federated-jwt mode for inbound JWT validation In federated-jwt mode the inbound jwt-validation plugin in AuthBridge reads /shared/client-id.txt to determine the expected audience for incoming requests. PR #476 skipped the entire ensureClientCredentialsSecret call in this mode, which meant client-id.txt was never written and every inbound request to the agent was rejected with HTTP 503 indefinitely. Controller: - Pass empty clientSecret (not clientID) to ensureClientCredentialsSecret in federated-jwt mode so the Secret is still created with client-id.txt only. - Remove the authType guard so patchTemplate still runs in federated-jwt mode, keeping the pod template annotation that causes the webhook to mount the Secret. - In ensureClientCredentialsSecret, omit client-secret.txt when clientSecret is empty rather than always writing it. Webhook: - Remove the federated-jwt guard that skipped annotation pre-population. The Secret still exists in federated-jwt mode (with client-id.txt only), so the volume mount annotation is needed for the first pod to come up correctly. Fixes #482 Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../clientregistration_controller.go | 42 +++++++++---------- .../webhook/v1alpha1/authbridge_webhook.go | 24 +++++------ 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index 8ef1c983..5906b966 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -364,28 +364,22 @@ 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. + // In federated-jwt mode pass an empty clientSecret so ensureClientCredentialsSecret + // writes only client-id.txt (not client-secret.txt). The inbound jwt-validation + // plugin in AuthBridge reads client-id.txt to determine the expected audience; without + // it, all inbound requests to the agent are rejected with 503 indefinitely. secretName := keycloakClientCredentialsSecretName(ns, workloadName) - 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) + credSecret := clientSecret + if authType == "federated-jwt" { + credSecret = "" + } + if err := r.ensureClientCredentialsSecret(ctx, owner, secretName, clientID, credSecret); 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 } logger.Info("operator client registration applied", @@ -571,7 +565,11 @@ func (r *ClientRegistrationReconciler) ensureClientCredentialsSecret(ctx context if sec.StringData == nil { sec.StringData = map[string]string{} } - sec.StringData["client-secret.txt"] = clientSecret + // client-secret.txt is omitted in federated-jwt mode (empty string) — agents + // authenticate via JWT-SVID and have no Keycloak client secret to store. + if clientSecret != "" { + sec.StringData["client-secret.txt"] = clientSecret + } sec.StringData["client-id.txt"] = clientID return controllerutil.SetControllerReference(owner, sec, r.Scheme) }) diff --git a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go index 9f186f64..e480af87 100644 --- a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go +++ b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go @@ -97,22 +97,20 @@ func (w *AuthBridgeWebhook) Handle(ctx context.Context, req admission.Request) a // 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. + // In federated-jwt mode the Secret still exists (written by the operator with only + // client-id.txt, no client-secret.txt) so the volume mount is still needed. The + // inbound jwt-validation plugin reads client-id.txt to determine the expected audience; + // without the mount the plugin polls forever and rejects all inbound traffic with 503. if pod.Annotations[injector.AnnotationKeycloakClientSecretName] == "" && clientreg.WorkloadWantsOperatorClientReg(pod.Labels, w.Mutator.GetFeatureGates().InjectTools) { - 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]) + 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]) } // Check if already injected (idempotency / reinvocation) From 60e1cc7439e7ab57df647994b1864240176a979b Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Wed, 15 Jul 2026 10:55:55 -0400 Subject: [PATCH 2/2] fix(controller): write empty client-secret.txt in federated-jwt mode A subPath mount for an absent Secret key creates a directory at the mount path rather than a file. Write client-secret.txt as an empty string so the mount produces a regular file. AuthBridge ignores the file in SPIFFE mode (it never reads client-secret.txt when identity.type=spiffe), but a directory at that path is confusing and fragile. Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../controller/clientregistration_controller.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index 5906b966..0ea3d6e2 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -565,11 +565,11 @@ func (r *ClientRegistrationReconciler) ensureClientCredentialsSecret(ctx context if sec.StringData == nil { sec.StringData = map[string]string{} } - // client-secret.txt is omitted in federated-jwt mode (empty string) — agents - // authenticate via JWT-SVID and have no Keycloak client secret to store. - if clientSecret != "" { - sec.StringData["client-secret.txt"] = clientSecret - } + // In federated-jwt mode clientSecret is empty — agents authenticate via JWT-SVID. + // We still write client-secret.txt (as an empty string) so the webhook's subPath + // mount produces a file rather than a directory (Kubernetes creates a directory + // when a subPath key is absent from the Secret). + sec.StringData["client-secret.txt"] = clientSecret sec.StringData["client-id.txt"] = clientID return controllerutil.SetControllerReference(owner, sec, r.Scheme) })