From 7e991edd7b2afdcbc42e57a7ef6edc04351f5938 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Sat, 6 Jun 2026 14:51:24 -0400 Subject: [PATCH 1/8] feat: Add JWT-SVID authentication method to Keycloak Admin client Add JWTSVIDGrantToken() method to keycloak.Admin for SPIFFE-based authentication. This enables the operator to authenticate using JWT-SVID instead of admin credentials. Method supports: - JWT-SVID client_credentials grant - client-assertion-type:jwt-spiffe (Keycloak 26.6.3+) - federated-jwt client authenticator Related: #349 Assisted-By: Claude Code Signed-off-by: Alan Cha --- kagenti-operator/internal/keycloak/admin.go | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/kagenti-operator/internal/keycloak/admin.go b/kagenti-operator/internal/keycloak/admin.go index ade60d1a..20f5b104 100644 --- a/kagenti-operator/internal/keycloak/admin.go +++ b/kagenti-operator/internal/keycloak/admin.go @@ -126,6 +126,48 @@ func (a *Admin) PasswordGrantToken(ctx context.Context, adminUser, adminPass str return token, err } +// JWTSVIDGrantToken authenticates using JWT-SVID and returns an access token. +// The clientID must be the operator's SPIFFE ID (e.g., spiffe://localtest.me/ns/kagenti-operator-system/sa/...). +// The operator client must be configured in Keycloak with: +// - clientAuthenticatorType: "federated-jwt" +// - attributes.jwt.credential.issuer: SPIFFE IdP alias +// - attributes.jwt.credential.sub: operator's SPIFFE ID +// - Service account with manage-clients role +func (a *Admin) JWTSVIDGrantToken(ctx context.Context, realm, clientID, jwtSVID string) (string, error) { + base := trimBaseURL(a.BaseURL) + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("client_id", clientID) + form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe") + form.Set("client_assertion", jwtSVID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + base+"/realms/"+url.PathEscape(realm)+"/protocol/openid-connect/token", + strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := a.httpc().Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("keycloak JWT-SVID token: status %d: %s", resp.StatusCode, truncate(body, 512)) + } + var tr adminTokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return "", fmt.Errorf("keycloak JWT-SVID token decode: %w", err) + } + if tr.AccessToken == "" { + return "", fmt.Errorf("keycloak JWT-SVID token: empty access_token") + } + return tr.AccessToken, nil +} + // RegisterOrFetchClient ensures an OAuth client exists and returns its internal UUID and client secret value. func (a *Admin) RegisterOrFetchClient(ctx context.Context, adminUser, adminPass string, p ClientRegistrationParams) (internalID, secret string, err error) { token, _, err := a.adminToken(ctx, adminUser, adminPass) From 1826877f0611fe99165d01b1714f0fc8a156867d Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Sat, 6 Jun 2026 14:56:36 -0400 Subject: [PATCH 2/8] feat: Integrate SPIFFE authentication into ClientRegistrationReconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SPIFFE JWT-SVID authentication support to the client registration controller, enabling the operator to authenticate without admin credentials. Changes: - Add UseSpiffeAuth, JWTSVIDPath, OperatorClientID fields to reconciler - Update reconcileOne() to use JWT-SVID when UseSpiffeAuth=true - Fall back to admin credentials when UseSpiffeAuth=false (default) - Read JWT-SVID from /opt/jwt_svid.token (written by spiffe-helper) Authentication flow: - SPIFFE path: Read JWT-SVID → JWTSVIDGrantToken() → Admin API - Legacy path: Read admin secret → PasswordGrantToken() → Admin API Both paths use the same Admin API for client registration and audience scope management, only the authentication method differs. Security benefits: - No admin credentials needed in operator namespace - Operator identity tied to Kubernetes ServiceAccount - JWT-SVIDs auto-rotate (short-lived) - Scoped to manage-clients role (not full admin) Backward compatible: defaults to admin credentials (UseSpiffeAuth=false). Related: #349 Assisted-By: Claude Code Signed-off-by: Alan Cha --- .../clientregistration_controller.go | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index c5ab9ba3..f758835a 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -10,6 +10,7 @@ package controller import ( "context" "fmt" + "os" "strings" "time" @@ -72,7 +73,21 @@ type ClientRegistrationReconciler struct { SpireTrustDomain string // KeycloakAdminTokenCache caches admin password-grant tokens by Keycloak URL and credentials to // avoid a token request on every reconcile. If nil, PasswordGrantToken is used without caching. + // Only used when UseSpiffeAuth is false. 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. + 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 + + // OperatorClientID is the operator's SPIFFE ID (e.g., spiffe://localtest.me/ns/kagenti-operator-system/sa/...). + // Only used when UseSpiffeAuth is true. + OperatorClientID string } func (r *ClientRegistrationReconciler) uncachedReader() client.Reader { @@ -228,19 +243,6 @@ func (r *ClientRegistrationReconciler) reconcileOne( return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - adminUser, adminPass, err := r.resolveKeycloakAdminCredentials(ctx) - if err != nil { - if apierrors.IsNotFound(err) { - logger.Info("waiting for Keycloak admin secret") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil - } - return ctrl.Result{}, err - } - if adminUser == "" || adminPass == "" { - logger.Info("Keycloak admin secret missing credentials") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil - } - spireEnabled := strings.EqualFold(strings.TrimSpace(ab.SpireEnabled), "true") clientName := ns + "/" + workloadName clientID, err := resolveKeycloakClientID(ns, workloadName, template.Spec.ServiceAccountName, spireEnabled, r.SpireTrustDomain) @@ -258,14 +260,53 @@ func (r *ClientRegistrationReconciler) reconcileOne( kc := keycloak.Admin{BaseURL: ab.KeycloakURL, HTTPClient: keycloak.DefaultHTTPClient()} var token string - if r.KeycloakAdminTokenCache != nil { - token, err = r.KeycloakAdminTokenCache.Token(ctx, &kc, adminUser, adminPass) + + // Authenticate to Keycloak: use JWT-SVID if enabled, otherwise admin credentials + if r.UseSpiffeAuth { + // SPIFFE authentication path + jwtSVIDPath := r.JWTSVIDPath + if jwtSVIDPath == "" { + jwtSVIDPath = "/opt/jwt_svid.token" + } + jwtSVID, err := os.ReadFile(jwtSVIDPath) + if err != nil { + logger.Error(err, "read JWT-SVID failed", "path", jwtSVIDPath) + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + if r.OperatorClientID == "" { + logger.Error(fmt.Errorf("OperatorClientID not configured"), "SPIFFE auth requires OperatorClientID") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + token, err = kc.JWTSVIDGrantToken(ctx, ab.KeycloakRealm, r.OperatorClientID, string(jwtSVID)) + if err != nil { + logger.Error(err, "Keycloak JWT-SVID authentication failed") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + logger.V(1).Info("authenticated with JWT-SVID", "clientId", r.OperatorClientID) } else { - token, err = kc.PasswordGrantToken(ctx, adminUser, adminPass) - } - if err != nil { - logger.Error(err, "Keycloak admin token failed") - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + // Admin credentials path (legacy) + adminUser, adminPass, err := r.resolveKeycloakAdminCredentials(ctx) + if err != nil { + if apierrors.IsNotFound(err) { + logger.Info("waiting for Keycloak admin secret") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + return ctrl.Result{}, err + } + if adminUser == "" || adminPass == "" { + logger.Info("Keycloak admin secret missing credentials") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + if r.KeycloakAdminTokenCache != nil { + token, err = r.KeycloakAdminTokenCache.Token(ctx, &kc, adminUser, adminPass) + } else { + token, err = kc.PasswordGrantToken(ctx, adminUser, adminPass) + } + if err != nil { + logger.Error(err, "Keycloak admin token failed") + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + logger.V(1).Info("authenticated with admin credentials") } agentClientUUID, clientSecret, err := kc.RegisterOrFetchClientWithToken(ctx, token, keycloak.ClientRegistrationParams{ Realm: ab.KeycloakRealm, From 23c4e9c8e2e098e01fc2ee9130a87f6a051f64f1 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Wed, 10 Jun 2026 10:15:14 -0400 Subject: [PATCH 3/8] fix: address security review comments in SPIFFE authentication 1. Path Traversal Protection: - Validate JWT-SVID path with filepath.Clean() and whitelist (/opt/, /var/run/secrets/) - Prevents reading arbitrary files via malicious JWTSVIDPath configuration 2. JWT-SVID Token Exposure Warning: - Add explicit comment marking JWT-SVID as sensitive bearer token - All error paths avoid including token in messages 3. Kubernetes Events for Silent Failures: - Add EventRecorder field to controller - Emit Warning events for JWT-SVID read failures, missing OperatorClientID, invalid paths - Makes configuration issues visible in `kubectl describe` 4. Validation Order Optimization: - Check OperatorClientID before file I/O to fail fast Addresses: https://github.com/kagenti/kagenti-operator/pull/349#pullrequestreview-4458483736 Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../clientregistration_controller.go | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/kagenti-operator/internal/controller/clientregistration_controller.go b/kagenti-operator/internal/controller/clientregistration_controller.go index f758835a..4b33906d 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller.go +++ b/kagenti-operator/internal/controller/clientregistration_controller.go @@ -11,6 +11,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "time" @@ -21,6 +22,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -88,6 +90,9 @@ type ClientRegistrationReconciler struct { // OperatorClientID is the operator's SPIFFE ID (e.g., spiffe://localtest.me/ns/kagenti-operator-system/sa/...). // Only used when UseSpiffeAuth is true. OperatorClientID string + + // Recorder emits Kubernetes Events to surface configuration issues visible in kubectl describe. + Recorder record.EventRecorder } func (r *ClientRegistrationReconciler) uncachedReader() client.Reader { @@ -264,22 +269,53 @@ 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") + if r.Recorder != nil { + r.Recorder.Event(owner, corev1.EventTypeWarning, "OperatorClientIDMissing", + "UseSpiffeAuth=true but OperatorClientID is empty. Check operator configuration.") + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + jwtSVIDPath := r.JWTSVIDPath if jwtSVIDPath == "" { jwtSVIDPath = "/opt/jwt_svid.token" } - jwtSVID, err := os.ReadFile(jwtSVIDPath) - if err != nil { - logger.Error(err, "read JWT-SVID failed", "path", jwtSVIDPath) - return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + + // 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") + 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) + } + return ctrl.Result{}, err // fail permanently on config error } - if r.OperatorClientID == "" { - logger.Error(fmt.Errorf("OperatorClientID not configured"), "SPIFFE auth requires OperatorClientID") + + jwtSVID, err := os.ReadFile(cleanPath) + if err != nil { + logger.Error(err, "read JWT-SVID failed", "path", cleanPath) + 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) + } 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)) if err != nil { logger.Error(err, "Keycloak JWT-SVID authentication failed") + if r.Recorder != nil { + r.Recorder.Event(owner, corev1.EventTypeWarning, "KeycloakAuthFailed", + "Failed to authenticate to Keycloak with JWT-SVID. Check SPIFFE IdP configuration in Keycloak.") + } return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } logger.V(1).Info("authenticated with JWT-SVID", "clientId", r.OperatorClientID) From 0b958e784307f63b08309e61b25b6b9ee0ae2829 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Wed, 10 Jun 2026 10:17:47 -0400 Subject: [PATCH 4/8] feat: add operator-spiffe-helper-config Helm template Add missing ConfigMap template and operator deployment updates to enable SPIFFE JWT-SVID authentication for the operator. Changes: - Add configmap-spiffe-helper.yaml template with JWT audience configuration - Add spiffe.operatorAuth values section with jwtAudience and jwtSVIDPath - Add spiffe-helper sidecar container to manager deployment - Add command-line flags: --use-spiffe-auth, --jwt-svid-path, --operator-client-id - Mount operator-spiffe-helper-config ConfigMap and shared JWT-SVID volume - Share SPIFFE CSI driver volume between manager and spiffe-helper JWT audience defaults to {{ keycloak.publicUrl }}/realms/{{ keycloak.realm }} and can be overridden via spiffe.operatorAuth.jwtAudience. Completes implementation started in PR #349. Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../manager/configmap-spiffe-helper.yaml | 27 ++++++++++ .../templates/manager/manager.yaml | 51 ++++++++++++++++++- charts/kagenti-operator/values.yaml | 14 +++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml diff --git a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml new file mode 100644 index 00000000..609725f5 --- /dev/null +++ b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml @@ -0,0 +1,27 @@ +{{- 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: {{ include "kagenti-operator.namespace" . }} + labels: + {{- include "kagenti-operator.labels" . | nindent 4 }} +data: + config.hcl: | + agent_address = "/spiffe-workload-api/spire-agent.sock" + cmd = "/bin/chmod" + cmd_args = "644,/opt/jwt_svid.token" + renew_signal = "" + cert_dir = "" + svid_file_name = "" + svid_bundle_file_name = "" + # jwt_audience MUST match Keycloak's realm issuer URL exactly. + # The JWT-SVID's aud claim must match the "iss" claim in tokens issued by Keycloak. + # Use the public/external URL, not internal K8s service names, as Keycloak's issuer + # typically uses the external hostname. + jwt_svids = [{ + jwt_audience = "{{ .Values.spiffe.operatorAuth.jwtAudience | default (printf "%s/realms/%s" .Values.keycloak.publicUrl .Values.keycloak.realm) }}" + 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 03d4ec68..13a05469 100644 --- a/charts/kagenti-operator/templates/manager/manager.yaml +++ b/charts/kagenti-operator/templates/manager/manager.yaml @@ -89,6 +89,11 @@ spec: - "--credential-wait-timeout={{ .Values.authbridgeConfig.credentialWaitTimeout }}" {{- end }} {{- 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: - {{ .Values.controllerManager.container.cmd }} image: {{ .Values.controllerManager.container.image.repository }}:{{ .Values.controllerManager.container.image.tag }} @@ -150,6 +155,42 @@ 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: ghcr.io/kagenti/kagenti-extensions/spiffe-helper:latest + imagePullPolicy: 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: 1000 + 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 }} @@ -173,9 +214,17 @@ spec: secret: secretName: kagenti-operator-metrics-server-cert {{- end }} - {{- if .Values.verifiedFetch.enabled }} + {{- if or .Values.verifiedFetch.enabled (and .Values.spiffe .Values.spiffe.enabled .Values.spiffe.operatorAuth .Values.spiffe.operatorAuth.enabled) }} - name: spiffe-workload-api csi: 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 352d1368..16f2a07d 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -158,6 +158,20 @@ signatureVerification: # How far before SVID expiry to trigger proactive workload restart svidExpiryGracePeriod: "30m" +# [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: 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" + # Feature gates — highest-priority layer in the injection precedence chain. # Set globalEnabled to false to disable ALL sidecar injection (kill switch). # Set individual gates to false to disable specific sidecars cluster-wide. From 618780f5c8a83a104c7e17aefe2852bfe6207bd9 Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Thu, 2 Jul 2026 10:33:10 -0400 Subject: [PATCH 5/8] fix(charts): use correct helper names in configmap-spiffe-helper.yaml The template referenced kagenti-operator.namespace and kagenti-operator.labels which are not defined in _helpers.tpl. The correct helpers are .Release.Namespace (no helper needed) and chart.labels. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../templates/manager/configmap-spiffe-helper.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml index 609725f5..8ed97f4b 100644 --- a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml +++ b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml @@ -4,9 +4,9 @@ apiVersion: v1 kind: ConfigMap metadata: name: operator-spiffe-helper-config - namespace: {{ include "kagenti-operator.namespace" . }} + namespace: {{ .Release.Namespace }} labels: - {{- include "kagenti-operator.labels" . | nindent 4 }} + {{- include "chart.labels" . | nindent 4 }} data: config.hcl: | agent_address = "/spiffe-workload-api/spire-agent.sock" From 1c43e7a4e23e3ae8e93bcac343d1545b53ad1fdf Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Thu, 2 Jul 2026 10:42:35 -0400 Subject: [PATCH 6/8] fix(operator): wire use-spiffe-auth flags into main.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ClientRegistrationReconciler gained UseSpiffeAuth, JWTSVIDPath, OperatorClientID and Recorder fields in a previous commit, and the chart was updated to pass --use-spiffe-auth, --jwt-svid-path, and --operator-client-id flags — but main.go was never updated to declare the variables, register the flags, or pass them to the reconciler. The operator would crash immediately (exit 2) with 'flag provided but not defined' when SPIFFE auth was enabled. Also wires the Recorder so authentication failures surface as Kubernetes Events on the affected Deployment. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- kagenti-operator/cmd/main.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index 85c2c7ff..8dcdc4fe 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -128,6 +128,9 @@ func main() { var spiffeIdpAlias string 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. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -197,6 +200,12 @@ func main() { "How long AuthBridge waits for Keycloak credentials to become available") 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)") + flag.StringVar(&operatorClientID, "operator-client-id", "", + "Operator SPIFFE ID (e.g. spiffe:///ns//sa/), used when --use-spiffe-auth=true") opts := zap.Options{ Development: false, @@ -560,6 +569,11 @@ func main() { setupLog.Info("Client registration controller enabled", "keycloakAdminSecretNamespace", keycloakAdminSecretNamespace, "operatorNamespace", operatorNS) + if useSpiffeAuth { + setupLog.Info("SPIFFE ID authentication enabled: using JWT-SVID for client registration", + "spireSocket", verifiedFetchSpiffeSocket, + "operatorSPIFFEID", operatorClientID) + } if err = (&controller.ClientRegistrationReconciler{ Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), @@ -568,6 +582,10 @@ func main() { KeycloakAdminSecretNamespace: keycloakAdminSecretNamespace, SpireTrustDomain: spireTrustDomain, KeycloakAdminTokenCache: &keycloak.CachedAdminTokenProvider{}, + UseSpiffeAuth: useSpiffeAuth, + JWTSVIDPath: jwtSVIDPath, + OperatorClientID: operatorClientID, + Recorder: mgr.GetEventRecorderFor("clientregistration"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClientRegistration") os.Exit(1) From 2b83ee3fad33b79f046b6ef5164917787a5090cd Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Thu, 2 Jul 2026 10:59:51 -0400 Subject: [PATCH 7/8] fix(charts): correct cmd_args separator in spiffe-helper config The spiffe-helper binary splits cmd_args on whitespace, not commas. Using '644,/opt/jwt_svid.token' passes a single argument to chmod instead of two, so chmod silently fails and the JWT-SVID file stays mode 600, causing 'permission denied' when the manager tries to read it. Change to space-separated: '644 /opt/jwt_svid.token'. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../templates/manager/configmap-spiffe-helper.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml index 8ed97f4b..b5ed348c 100644 --- a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml +++ b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml @@ -11,7 +11,7 @@ data: config.hcl: | agent_address = "/spiffe-workload-api/spire-agent.sock" cmd = "/bin/chmod" - cmd_args = "644,/opt/jwt_svid.token" + cmd_args = "644 /opt/jwt_svid.token" renew_signal = "" cert_dir = "" svid_file_name = "" From 8f021d05514856f9a44e9e658baf2adb47483c5a Mon Sep 17 00:00:00 2001 From: Alan Cha Date: Thu, 2 Jul 2026 11:09:04 -0400 Subject: [PATCH 8/8] fix(charts): fix JWT-SVID file permissions for manager access Two issues: 1. The cmd/cmd_args chmod hook fires on X.509 SVID renewals only, not JWT SVIDs. The chmod never ran, leaving /opt/jwt_svid.token mode 600 owned by UID 1000 (spiffe-helper). The manager (UID 65532) could not read it, causing perpetual 'permission denied' errors. 2. Fix: run spiffe-helper as UID 65532 (matching the manager Dockerfile's USER 65532:65532 directive). Both containers now share the same UID so the manager can read files created by spiffe-helper. Also removes the non-functional cmd/cmd_args from the ConfigMap to avoid confusion. Signed-off-by: Alan Cha Assisted-By: Claude (Anthropic AI) Signed-off-by: Alan Cha --- .../templates/manager/configmap-spiffe-helper.yaml | 9 +++------ charts/kagenti-operator/templates/manager/manager.yaml | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml index b5ed348c..7abe2f92 100644 --- a/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml +++ b/charts/kagenti-operator/templates/manager/configmap-spiffe-helper.yaml @@ -10,16 +10,13 @@ metadata: data: config.hcl: | agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "/bin/chmod" - cmd_args = "644 /opt/jwt_svid.token" + # 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_audience MUST match Keycloak's realm issuer URL exactly. - # The JWT-SVID's aud claim must match the "iss" claim in tokens issued by Keycloak. - # Use the public/external URL, not internal K8s service names, as Keycloak's issuer - # typically uses the external hostname. jwt_svids = [{ jwt_audience = "{{ .Values.spiffe.operatorAuth.jwtAudience | default (printf "%s/realms/%s" .Values.keycloak.publicUrl .Values.keycloak.realm) }}" jwt_svid_file_name = "/opt/jwt_svid.token" diff --git a/charts/kagenti-operator/templates/manager/manager.yaml b/charts/kagenti-operator/templates/manager/manager.yaml index 13a05469..9c8dd0a5 100644 --- a/charts/kagenti-operator/templates/manager/manager.yaml +++ b/charts/kagenti-operator/templates/manager/manager.yaml @@ -179,7 +179,7 @@ spec: securityContext: allowPrivilegeEscalation: false runAsNonRoot: true - runAsUser: 1000 + runAsUser: 65532 capabilities: drop: - ALL