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" 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). 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", 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)