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
Expand Up @@ -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 }}
4 changes: 2 additions & 2 deletions charts/kagenti-operator/templates/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 11 additions & 11 deletions charts/kagenti-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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