Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{{- 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 }}
51 changes: 50 additions & 1 deletion charts/kagenti-operator/templates/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ spec:
- "--sigstore-trusted-root-configmap-key={{ .Values.sigstore.cardVerification.trustedRoot.configMapKey }}"
{{- 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 }}
Expand Down Expand Up @@ -181,6 +186,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: {{ .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 All @@ -204,9 +245,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 }}
19 changes: 19 additions & 0 deletions charts/kagenti-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,25 @@ sigstore:
configMapNamespace: ""
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: 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).
# Set individual gates to false to disable specific sidecars cluster-wide.
Expand Down
18 changes: 18 additions & 0 deletions kagenti-operator/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,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.")
Expand Down Expand Up @@ -207,6 +210,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://<domain>/ns/<ns>/sa/<sa>), used when --use-spiffe-auth=true")

var enableSigstoreVerification bool
var sigstoreAuditMode bool
Expand Down Expand Up @@ -688,6 +697,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(),
Expand All @@ -696,6 +710,10 @@ func main() {
KeycloakAdminSecretNamespace: keycloakAdminSecretNamespace,
SpireTrustDomain: spireTrustDomain,
KeycloakAdminTokenCache: &keycloak.CachedAdminTokenProvider{},
UseSpiffeAuth: useSpiffeAuth,
JWTSVIDPath: jwtSVIDPath,
OperatorClientID: operatorClientID,
Recorder: mgr.GetEventRecorderFor("clientregistration"), //nolint:staticcheck
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ClientRegistration")
os.Exit(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ package controller
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
Expand All @@ -21,6 +23,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"
Expand Down Expand Up @@ -73,7 +76,24 @@ 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

// Recorder emits Kubernetes Events to surface configuration issues visible in kubectl describe.
Recorder record.EventRecorder
}

func (r *ClientRegistrationReconciler) uncachedReader() client.Reader {
Expand Down Expand Up @@ -229,19 +249,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 := r.SpireTrustDomain != ""
clientName := ns + "/" + workloadName
clientID, err := resolveKeycloakClientID(ns, workloadName, template.Spec.ServiceAccountName, spireEnabled, r.SpireTrustDomain)
Expand All @@ -259,14 +266,84 @@ 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
// 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"
}

// 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
}

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)
} 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,
Expand Down
42 changes: 42 additions & 0 deletions kagenti-operator/internal/keycloak/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading