diff --git a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml deleted file mode 100644 index 75cb7079..00000000 --- a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml +++ /dev/null @@ -1,24 +0,0 @@ -{{- if and .Values.spiffe .Values.spiffe.enabled .Values.spiffe.operatorAuth .Values.spiffe.operatorAuth.enabled }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: operator-spiffe-helper-config - namespace: {{ .Release.Namespace }} - labels: - {{- include "chart.labels" . | nindent 4 }} -data: - config.hcl: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - # cmd/cmd_args fire on X.509 SVID renewals only, not JWT SVIDs. - # File permissions are handled by running spiffe-helper as the same UID - # as the manager container (65532) so it can read the 600-mode file. - renew_signal = "" - cert_dir = "" - svid_file_name = "" - svid_bundle_file_name = "" - 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_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..92f38458 100644 --- a/charts/kagenti-operator/templates/manager/manager.yaml +++ b/charts/kagenti-operator/templates/manager/manager.yaml @@ -118,7 +118,6 @@ spec: {{- end }} {{- if and .Values.spiffe .Values.spiffe.enabled .Values.spiffe.operatorAuth .Values.spiffe.operatorAuth.enabled }} - "--use-spiffe-auth=true" - - "--jwt-svid-path={{ .Values.spiffe.operatorAuth.jwtSVIDPath | default "/opt/jwt_svid.token" }}" - "--operator-client-id=spiffe://{{ .Values.signatureVerification.spireTrustDomain | default "localtest.me" }}/ns/{{ .Release.Namespace }}/sa/{{ .Values.controllerManager.serviceAccountName }}" {{- end }} command: @@ -186,42 +185,6 @@ spec: mountPath: /spiffe-workload-api readOnly: true {{- end }} - {{- if and .Values.spiffe .Values.spiffe.enabled .Values.spiffe.operatorAuth .Values.spiffe.operatorAuth.enabled }} - - name: jwt-svid - mountPath: /opt - readOnly: true - {{- 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" }} - args: - - "-config" - - "/etc/spiffe-helper/config.hcl" - volumeMounts: - - name: spiffe-workload-api - mountPath: /spiffe-workload-api - readOnly: true - - name: spiffe-helper-config - mountPath: /etc/spiffe-helper - readOnly: true - - name: jwt-svid - mountPath: /opt - securityContext: - allowPrivilegeEscalation: false - runAsNonRoot: true - runAsUser: 65532 - capabilities: - drop: - - ALL - resources: - requests: - cpu: 10m - memory: 32Mi - limits: - cpu: 100m - memory: 64Mi - {{- end }} securityContext: {{- toYaml .Values.controllerManager.securityContext | nindent 8 }} serviceAccountName: {{ .Values.controllerManager.serviceAccountName }} @@ -251,11 +214,3 @@ spec: driver: "csi.spiffe.io" readOnly: true {{- end }} - {{- if and .Values.spiffe .Values.spiffe.enabled .Values.spiffe.operatorAuth .Values.spiffe.operatorAuth.enabled }} - - name: spiffe-helper-config - configMap: - name: operator-spiffe-helper-config - - name: jwt-svid - emptyDir: - medium: Memory - {{- end }} diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index acdde623..c612df12 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -190,23 +190,21 @@ 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/cmd/main.go b/kagenti-operator/cmd/main.go index 1be8a4eb..e9c42c20 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -135,7 +135,6 @@ func main() { var credentialWaitTimeout string var enableAuthbridgeConfig bool var useSpiffeAuth bool - var jwtSVIDPath string var operatorClientID string flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ @@ -211,9 +210,8 @@ func main() { flag.BoolVar(&enableAuthbridgeConfig, "enable-authbridge-config", true, "Reconcile authbridge-config ConfigMap in namespaces labeled kagenti-enabled=true") flag.BoolVar(&useSpiffeAuth, "use-spiffe-auth", false, - "Use JWT-SVID authentication for Keycloak client registration instead of admin credentials") - flag.StringVar(&jwtSVIDPath, "jwt-svid-path", "/opt/jwt_svid.token", - "Path to JWT-SVID file written by spiffe-helper sidecar (used when --use-spiffe-auth=true)") + "Use JWT-SVID authentication for Keycloak client registration instead of admin credentials. "+ + "JWT-SVIDs are fetched directly from the SPIRE workload API (--verified-fetch-spiffe-socket).") flag.StringVar(&operatorClientID, "operator-client-id", "", "Operator SPIFFE ID (e.g. spiffe:///ns//sa/), used when --use-spiffe-auth=true") @@ -711,7 +709,7 @@ func main() { SpireTrustDomain: spireTrustDomain, KeycloakAdminTokenCache: &keycloak.CachedAdminTokenProvider{}, UseSpiffeAuth: useSpiffeAuth, - JWTSVIDPath: jwtSVIDPath, + SpiffeSocket: verifiedFetchSpiffeSocket, OperatorClientID: operatorClientID, Recorder: mgr.GetEventRecorderFor("clientregistration"), //nolint:staticcheck }).SetupWithManager(mgr); err != nil { diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index 9efcb04a..086b2083 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -10,12 +10,13 @@ package controller import ( "context" "fmt" - "os" - "path/filepath" "sort" "strings" "time" + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "github.com/spiffe/go-spiffe/v2/workloadapi" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -80,15 +81,16 @@ type ClientRegistrationReconciler struct { KeycloakAdminTokenCache *keycloak.CachedAdminTokenProvider // UseSpiffeAuth enables JWT-SVID authentication instead of admin credentials. - // When true, the operator authenticates to Keycloak with its JWT-SVID and uses - // the Admin API with manage-clients role. When false, uses admin credentials. + // When true, the operator fetches a JWT-SVID from SPIRE via the go-spiffe SDK + // and uses it with the Keycloak Admin API (manage-clients role only). + // When false, uses admin credentials. UseSpiffeAuth bool - // JWTSVIDPath is the file path to read the operator's JWT-SVID from. - // Only used when UseSpiffeAuth is true. Default: /opt/jwt_svid.token - JWTSVIDPath string + // SpiffeSocket is the SPIRE workload API socket address used to fetch JWT-SVIDs. + // Only used when UseSpiffeAuth is true. Defaults to the verified-fetch socket. + SpiffeSocket string - // OperatorClientID is the operator's SPIFFE ID (e.g., spiffe://localtest.me/ns/kagenti-operator-system/sa/...). + // OperatorClientID is the operator's SPIFFE ID (e.g., spiffe://localtest.me/ns/kagenti-system/sa/controller-manager). // Only used when UseSpiffeAuth is true. OperatorClientID string @@ -269,8 +271,6 @@ func (r *ClientRegistrationReconciler) reconcileOne( // Authenticate to Keycloak: use JWT-SVID if enabled, otherwise admin credentials if r.UseSpiffeAuth { - // SPIFFE authentication path - // Validate OperatorClientID before file I/O to fail fast on misconfiguration if r.OperatorClientID == "" { err := fmt.Errorf("OperatorClientID not configured") logger.Error(err, "SPIFFE auth requires OperatorClientID") @@ -281,36 +281,40 @@ func (r *ClientRegistrationReconciler) reconcileOne( return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - jwtSVIDPath := r.JWTSVIDPath - if jwtSVIDPath == "" { - jwtSVIDPath = "/opt/jwt_svid.token" + socket := r.SpiffeSocket + if socket == "" { + socket = "unix:///spiffe-workload-api/spire-agent.sock" } - // Path traversal protection: only allow reading from designated directories - cleanPath := filepath.Clean(jwtSVIDPath) - if !strings.HasPrefix(cleanPath, "/opt/") && !strings.HasPrefix(cleanPath, "/var/run/secrets/") { - err := fmt.Errorf("JWT-SVID path %q outside allowed directories (/opt/, /var/run/secrets/)", jwtSVIDPath) - logger.Error(err, "invalid JWT-SVID path") + // Fetch a JWT-SVID directly from the SPIRE workload API using the go-spiffe SDK. + // This is the same socket used by verifiedFetch — no sidecar or file needed. + // The audience must be the Keycloak realm's issuer URL (external URL, not in-cluster). + // The JWT-SVID audience must be the Keycloak realm's issuer URL (external/public URL). + // Keycloak's FederatedJWTClientValidator checks aud == its own issuer string. + audience := fmt.Sprintf("%s/realms/%s", strings.TrimRight(ab.KeycloakURL, "/"), ab.KeycloakRealm) + spiffeClient, err := workloadapi.New(ctx, workloadapi.WithAddr(socket)) + if err != nil { + logger.Error(err, "failed to create SPIFFE workload API client") if r.Recorder != nil { - r.Recorder.Eventf(owner, corev1.EventTypeWarning, "InvalidJWTSVIDPath", - "JWT-SVID path %q rejected: must be under /opt/ or /var/run/secrets/", jwtSVIDPath) + r.Recorder.Eventf(owner, corev1.EventTypeWarning, "SPIFFEClientFailed", + "Failed to connect to SPIRE workload API at %s: %v", socket, err) } - return ctrl.Result{}, err // fail permanently on config error + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } + defer spiffeClient.Close() - jwtSVID, err := os.ReadFile(cleanPath) + svid, err := spiffeClient.FetchJWTSVID(ctx, jwtsvid.Params{Audience: audience}) if err != nil { - logger.Error(err, "read JWT-SVID failed", "path", cleanPath) + logger.Error(err, "failed to fetch JWT-SVID from SPIRE") if r.Recorder != nil { - r.Recorder.Eventf(owner, corev1.EventTypeWarning, "JWTSVIDReadFailed", - "Failed to read JWT-SVID from %s: %v. Check spiffe-helper sidecar configuration.", cleanPath, err) + r.Recorder.Event(owner, corev1.EventTypeWarning, "JWTSVIDFetchFailed", + "Failed to fetch JWT-SVID from SPIRE workload API. Check SPIRE is running and the operator has a registered identity.") } return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - // WARNING: JWT-SVID is a bearer token - must never appear in logs or error messages - // to prevent token exposure. All code paths must handle jwtSVID as sensitive data. - token, err = kc.JWTSVIDGrantToken(ctx, ab.KeycloakRealm, r.OperatorClientID, string(jwtSVID)) + // WARNING: JWT-SVID is a bearer token — must never appear in logs or error messages. + token, err = kc.JWTSVIDGrantToken(ctx, ab.KeycloakRealm, r.OperatorClientID, svid.Marshal()) if err != nil { logger.Error(err, "Keycloak JWT-SVID authentication failed") if r.Recorder != nil { @@ -319,7 +323,7 @@ func (r *ClientRegistrationReconciler) reconcileOne( } return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - logger.V(1).Info("authenticated with JWT-SVID", "clientId", r.OperatorClientID) + logger.V(1).Info("authenticated with JWT-SVID via SPIRE workload API", "clientId", r.OperatorClientID) } else { // Admin credentials path (legacy) adminUser, adminPass, err := r.resolveKeycloakAdminCredentials(ctx) @@ -370,15 +374,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)