Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

45 changes: 0 additions & 45 deletions charts/kagenti-operator/templates/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
24 changes: 11 additions & 13 deletions charts/kagenti-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 3 additions & 5 deletions kagenti-operator/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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. "+
Expand Down Expand Up @@ -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://<domain>/ns/<ns>/sa/<sa>), used when --use-spiffe-auth=true")

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
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"
Expand Down Expand Up @@ -80,15 +81,16 @@
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

Expand Down Expand Up @@ -269,8 +271,6 @@

// 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")
Expand All @@ -281,36 +281,40 @@
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()

Check failure on line 304 in kagenti-operator/internal/controller/clientregistration_controller.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `spiffeClient.Close` is not checked (errcheck)

Check failure on line 304 in kagenti-operator/internal/controller/clientregistration_controller.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `spiffeClient.Close` is not checked (errcheck)

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 {
Expand All @@ -319,7 +323,7 @@
}
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)
Expand Down Expand Up @@ -370,15 +374,28 @@
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading