Skip to content

Commit 21f506d

Browse files
authored
Merge pull request #356 from huang195/fix/mutator-eager-keycloak-secret-mount
fix(webhook): pre-populate Keycloak client-credentials annotation at admission
2 parents 01f875a + e250a10 commit 21f506d

9 files changed

Lines changed: 391 additions & 31 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
*/
7+
8+
// Package clientreg holds the naming and eligibility logic shared by the AuthBridge mutating
9+
// webhook and the ClientRegistration controller. Both sides must agree on (a) the Secret name
10+
// and (b) whether a workload is eligible for operator-managed Keycloak client registration,
11+
// so the webhook can pre-populate the pod annotation at admission without waiting for the
12+
// controller to run. Centralizing these prevents the two sides from drifting.
13+
package clientreg
14+
15+
import (
16+
"crypto/sha256"
17+
"encoding/hex"
18+
"fmt"
19+
)
20+
21+
const (
22+
// LabelClientRegistrationInject: when "true", the workload opts into the legacy
23+
// client-registration sidecar and operator-managed registration is skipped.
24+
LabelClientRegistrationInject = "kagenti.io/client-registration-inject"
25+
26+
// LabelAgentType distinguishes agents from tools; operator-managed registration runs for
27+
// agents unconditionally and for tools only when the injectTools feature gate is on.
28+
LabelAgentType = "kagenti.io/type"
29+
LabelValueAgent = "agent"
30+
// LabelValueTool matches agentv1alpha1.RuntimeTypeTool — kept here as a string to avoid
31+
// importing the API package from this leaf utility.
32+
LabelValueTool = "tool"
33+
34+
// AnnotationKeycloakClientSecretName is set on workload pod templates (by the controller)
35+
// and pre-populated on new pods (by the webhook) to signal the name of the Secret holding
36+
// Keycloak client credentials. The webhook mounts that Secret into /shared/client-id.txt
37+
// and /shared/client-secret.txt for any container that already mounts the shared-data volume.
38+
AnnotationKeycloakClientSecretName = "kagenti.io/keycloak-client-credentials-secret-name"
39+
)
40+
41+
// KeycloakClientCredentialsSecretName returns the deterministic name of the Secret the
42+
// ClientRegistration controller produces for (namespace, workload). It is a pure function of
43+
// those inputs only — the webhook can compute it at admission time without consulting the
44+
// API server, so a Secret volume can be declared before the controller has run. Kubelet will
45+
// retry the mount until the Secret appears.
46+
func KeycloakClientCredentialsSecretName(namespace, workload string) string {
47+
sum := sha256.Sum256([]byte(namespace + "\000" + workload + "\000kagenti-keycloak-client-credentials"))
48+
return "kagenti-keycloak-client-credentials-" + hex.EncodeToString(sum[:8])
49+
}
50+
51+
// SkipReason returns a non-empty human-readable reason when operator-managed client registration
52+
// should not run for the workload. Empty string means "proceed". Both the controller's
53+
// reconcileOne and the webhook's admission handler use this to stay in lockstep.
54+
func SkipReason(labels map[string]string, injectTools bool) string {
55+
if labels == nil {
56+
return "pod template has no labels"
57+
}
58+
if labels[LabelClientRegistrationInject] == "true" {
59+
return fmt.Sprintf("%s is \"true\" (legacy webhook client-registration sidecar; operator-managed registration disabled for this workload)", LabelClientRegistrationInject)
60+
}
61+
switch labels[LabelAgentType] {
62+
case LabelValueAgent:
63+
return ""
64+
case LabelValueTool:
65+
if !injectTools {
66+
return "kagenti.io/type is tool but cluster injectTools feature gate is disabled"
67+
}
68+
return ""
69+
default:
70+
t := labels[LabelAgentType]
71+
if t == "" {
72+
return "kagenti.io/type label is missing or not agent/tool"
73+
}
74+
return fmt.Sprintf("kagenti.io/type=%q is not agent or tool", t)
75+
}
76+
}
77+
78+
// WorkloadWantsOperatorClientReg returns true when the workload's labels and the cluster
79+
// injectTools gate both permit operator-managed client registration.
80+
func WorkloadWantsOperatorClientReg(labels map[string]string, injectTools bool) bool {
81+
return SkipReason(labels, injectTools) == ""
82+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
*/
7+
8+
package clientreg
9+
10+
import "testing"
11+
12+
func TestKeycloakClientCredentialsSecretName_Deterministic(t *testing.T) {
13+
a := KeycloakClientCredentialsSecretName("team1", "weather-agent")
14+
b := KeycloakClientCredentialsSecretName("team1", "weather-agent")
15+
if a != b {
16+
t.Fatalf("expected deterministic output, got %q and %q", a, b)
17+
}
18+
if a == "" {
19+
t.Fatalf("expected non-empty secret name")
20+
}
21+
}
22+
23+
func TestKeycloakClientCredentialsSecretName_DistinctByInputs(t *testing.T) {
24+
cases := []struct{ ns, w string }{
25+
{"team1", "a"},
26+
{"team1", "b"},
27+
{"team2", "a"},
28+
}
29+
seen := map[string]string{}
30+
for _, c := range cases {
31+
got := KeycloakClientCredentialsSecretName(c.ns, c.w)
32+
key := c.ns + "/" + c.w
33+
if prev, ok := seen[got]; ok {
34+
t.Fatalf("collision: %s and %s both produced %s", prev, key, got)
35+
}
36+
seen[got] = key
37+
}
38+
}
39+
40+
func TestSkipReason(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
labels map[string]string
44+
injectTools bool
45+
wantSkip bool
46+
}{
47+
{"nil labels", nil, true, true},
48+
{"empty labels", map[string]string{}, true, true},
49+
{"legacy sidecar opt-in", map[string]string{LabelClientRegistrationInject: "true", LabelAgentType: LabelValueAgent}, true, true},
50+
{"agent proceeds", map[string]string{LabelAgentType: LabelValueAgent}, false, false},
51+
{"tool with gate on proceeds", map[string]string{LabelAgentType: LabelValueTool}, true, false},
52+
{"tool with gate off skipped", map[string]string{LabelAgentType: LabelValueTool}, false, true},
53+
{"unknown type skipped", map[string]string{LabelAgentType: "other"}, true, true},
54+
}
55+
56+
for _, tc := range tests {
57+
t.Run(tc.name, func(t *testing.T) {
58+
reason := SkipReason(tc.labels, tc.injectTools)
59+
gotSkip := reason != ""
60+
if gotSkip != tc.wantSkip {
61+
t.Fatalf("SkipReason(%v, %v) = %q; wantSkip=%v", tc.labels, tc.injectTools, reason, tc.wantSkip)
62+
}
63+
wants := WorkloadWantsOperatorClientReg(tc.labels, tc.injectTools)
64+
if wants == tc.wantSkip {
65+
t.Fatalf("WorkloadWantsOperatorClientReg disagrees with SkipReason for %v", tc)
66+
}
67+
})
68+
}
69+
}

kagenti-operator/internal/controller/clientregistration_controller.go

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ package controller
99

1010
import (
1111
"context"
12-
"crypto/sha256"
13-
"encoding/hex"
1412
"fmt"
1513
"strings"
1614
"time"
@@ -32,7 +30,7 @@ import (
3230
"sigs.k8s.io/controller-runtime/pkg/predicate"
3331
"sigs.k8s.io/yaml"
3432

35-
agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1"
33+
"github.com/kagenti/operator/internal/clientreg"
3634
"github.com/kagenti/operator/internal/keycloak"
3735
)
3836

@@ -44,10 +42,12 @@ const (
4442
// LabelClientRegistrationInject: when not "true", the operator registers the OAuth client and sets
4543
// AnnotationKeycloakClientSecretName. Value "true" opts the workload into the legacy webhook
4644
// client-registration sidecar; the operator skips registration for that workload.
47-
LabelClientRegistrationInject = "kagenti.io/client-registration-inject"
45+
// Re-exported from internal/clientreg so existing callers keep working.
46+
LabelClientRegistrationInject = clientreg.LabelClientRegistrationInject
4847

4948
// AnnotationKeycloakClientSecretName must match kagenti-webhook injector.AnnotationKeycloakClientSecretName.
50-
AnnotationKeycloakClientSecretName = "kagenti.io/keycloak-client-credentials-secret-name"
49+
// Re-exported from internal/clientreg to give both the controller and the webhook one source of truth.
50+
AnnotationKeycloakClientSecretName = clientreg.AnnotationKeycloakClientSecretName
5151
)
5252

5353
// ClientRegistrationReconciler registers OAuth clients in Keycloak and patches agent/tool workloads that
@@ -303,34 +303,14 @@ func injectKeycloakClientCredentialsAnnotation(template *corev1.PodTemplateSpec,
303303
return true
304304
}
305305

306-
// keycloakClientCredentialsSkipReason returns a non-empty human-readable reason when this controller should
307-
// not process the workload; empty string means reconcile should continue.
306+
// keycloakClientCredentialsSkipReason and workloadWantsOperatorClientReg delegate to internal/clientreg
307+
// so the controller's reconcile decision and the webhook's admission decision stay in lockstep.
308308
func keycloakClientCredentialsSkipReason(labels map[string]string, injectTools bool) string {
309-
if labels == nil {
310-
return "pod template has no labels"
311-
}
312-
if labels[LabelClientRegistrationInject] == "true" {
313-
return fmt.Sprintf("%s is \"true\" (legacy webhook client-registration sidecar; operator-managed registration disabled for this workload)", LabelClientRegistrationInject)
314-
}
315-
switch labels[LabelAgentType] {
316-
case LabelValueAgent:
317-
return ""
318-
case string(agentv1alpha1.RuntimeTypeTool):
319-
if !injectTools {
320-
return "kagenti.io/type is tool but cluster injectTools feature gate is disabled"
321-
}
322-
return ""
323-
default:
324-
t := labels[LabelAgentType]
325-
if t == "" {
326-
return "kagenti.io/type label is missing or not agent/tool"
327-
}
328-
return fmt.Sprintf("kagenti.io/type=%q is not agent or tool", t)
329-
}
309+
return clientreg.SkipReason(labels, injectTools)
330310
}
331311

332312
func workloadWantsOperatorClientReg(labels map[string]string, injectTools bool) bool {
333-
return keycloakClientCredentialsSkipReason(labels, injectTools) == ""
313+
return clientreg.WorkloadWantsOperatorClientReg(labels, injectTools)
334314
}
335315

336316
type authbridgeConfig struct {
@@ -440,9 +420,11 @@ func resolveKeycloakClientID(namespace, workloadName, serviceAccount string, spi
440420
return fmt.Sprintf("spiffe://%s/ns/%s/sa/%s", trustDomain, namespace, sa), nil
441421
}
442422

423+
// keycloakClientCredentialsSecretName is a thin alias over the shared helper so call sites in this
424+
// package stay unchanged. The shared implementation lives in internal/clientreg so the AuthBridge
425+
// mutating webhook can compute the same name at admission time.
443426
func keycloakClientCredentialsSecretName(namespace, workload string) string {
444-
sum := sha256.Sum256([]byte(namespace + "\000" + workload + "\000kagenti-keycloak-client-credentials"))
445-
return "kagenti-keycloak-client-credentials-" + hex.EncodeToString(sum[:8])
427+
return clientreg.KeycloakClientCredentialsSecretName(namespace, workload)
446428
}
447429

448430
func (r *ClientRegistrationReconciler) ensureClientCredentialsSecret(ctx context.Context, owner client.Object, secretName, clientID, clientSecret string) error {

kagenti-operator/internal/webhook/injector/pod_mutator.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,15 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp
413413
}
414414
}
415415

416+
// Mount operator-managed Keycloak client credentials for any container that uses
417+
// shared-data (authbridge-proxy reads /shared/client-id.txt and /shared/client-secret.txt
418+
// for its jwt-validation + token-exchange plugins). Without this, proxy-sidecar mode
419+
// polls the credential files forever and rejects every inbound request with
420+
// 503 "identity not yet configured (credentials pending)". Envoy-sidecar mode
421+
// already calls this helper further down; the proxy-sidecar branch returns early,
422+
// so it needs its own invocation.
423+
ApplyKeycloakClientCredentialsSecretVolumes(podSpec, annotations)
424+
416425
mutatorLog.Info("proxy-sidecar mode injection complete",
417426
"namespace", namespace, "crName", crName,
418427
"image", builder.cfg.Images.AuthBridgeLight,

kagenti-operator/internal/webhook/injector/pod_mutator_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,71 @@ func TestInjectAuthBridge_ProxySidecarMode_InjectsCorrectly(t *testing.T) {
746746
}
747747
}
748748

749+
func TestInjectAuthBridge_ProxySidecarMode_MountsKeycloakCredentials(t *testing.T) {
750+
// Regression: the proxy-sidecar branch used to return before reaching
751+
// ApplyKeycloakClientCredentialsSecretVolumes. That left authbridge-proxy polling
752+
// /shared/client-id.txt forever and returning 503 "identity not yet configured".
753+
m := newTestMutator()
754+
ctx := context.Background()
755+
756+
podSpec := &corev1.PodSpec{
757+
ServiceAccountName: "my-agent",
758+
Containers: []corev1.Container{
759+
{Name: "agent", Image: "my-agent:latest"},
760+
},
761+
}
762+
labels := map[string]string{
763+
KagentiTypeLabel: KagentiTypeAgent,
764+
}
765+
annotations := map[string]string{
766+
AnnotationAuthBridgeMode: ModeProxySidecar,
767+
AnnotationKeycloakClientSecretName: "kagenti-keycloak-client-credentials-abc12345",
768+
}
769+
770+
mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations)
771+
if err != nil {
772+
t.Fatalf("unexpected error: %v", err)
773+
}
774+
if !mutated {
775+
t.Fatal("proxy-sidecar mode should mutate the pod")
776+
}
777+
778+
// The Secret volume must be declared so kubelet can resolve it.
779+
volFound := false
780+
for _, v := range podSpec.Volumes {
781+
if v.Secret != nil && v.Secret.SecretName == "kagenti-keycloak-client-credentials-abc12345" {
782+
volFound = true
783+
break
784+
}
785+
}
786+
if !volFound {
787+
t.Error("expected a Secret volume for operator-managed Keycloak client credentials; got none")
788+
}
789+
790+
// The authbridge-proxy container must mount client-id.txt and client-secret.txt
791+
// via subPath so its plugins can read them.
792+
var proxyMounts []corev1.VolumeMount
793+
for _, c := range podSpec.Containers {
794+
if c.Name == AuthBridgeProxyContainerName {
795+
proxyMounts = c.VolumeMounts
796+
break
797+
}
798+
}
799+
haveIDMount, haveSecretMount := false, false
800+
for _, m := range proxyMounts {
801+
if m.MountPath == "/shared/client-id.txt" && m.SubPath == "client-id.txt" {
802+
haveIDMount = true
803+
}
804+
if m.MountPath == "/shared/client-secret.txt" && m.SubPath == "client-secret.txt" {
805+
haveSecretMount = true
806+
}
807+
}
808+
if !haveIDMount || !haveSecretMount {
809+
t.Errorf("authbridge-proxy missing Keycloak credential subPath mounts: id=%v secret=%v",
810+
haveIDMount, haveSecretMount)
811+
}
812+
}
813+
749814
func TestInjectHTTPProxyEnv_DoesNotDuplicate(t *testing.T) {
750815
c := &corev1.Container{
751816
Name: "agent",

kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"net/http"
2323
"strings"
2424

25+
"github.com/kagenti/operator/internal/clientreg"
2526
"github.com/kagenti/operator/internal/webhook/injector"
2627
corev1 "k8s.io/api/core/v1"
2728
ctrl "sigs.k8s.io/controller-runtime"
@@ -87,6 +88,26 @@ func (w *AuthBridgeWebhook) Handle(ctx context.Context, req admission.Request) a
8788
// but GenerateName is set by the owning controller (e.g. "myapp-7d4f8b9c5-").
8889
resourceName := deriveWorkloadName(&pod)
8990

91+
// Pre-populate the Keycloak client-credentials annotation for workloads eligible for
92+
// operator-managed client registration. The ClientRegistration controller produces the
93+
// Secret asynchronously (it calls out to Keycloak), so at first-deploy admission the
94+
// Secret typically does not exist yet. By setting the annotation here and letting
95+
// ApplyKeycloakClientCredentialsSecretVolumes declare the Secret volume with Optional=false,
96+
// kubelet will wait for the Secret to appear and mount it — no pod restart required.
97+
// Without this, the first pod comes up with an empty /shared/ and envoy returns 503
98+
// "identity not yet configured (credentials pending)" until the user deletes the pod.
99+
if pod.Annotations[injector.AnnotationKeycloakClientSecretName] == "" &&
100+
clientreg.WorkloadWantsOperatorClientReg(pod.Labels, w.Mutator.GetFeatureGates().InjectTools) {
101+
if pod.Annotations == nil {
102+
pod.Annotations = map[string]string{}
103+
}
104+
pod.Annotations[injector.AnnotationKeycloakClientSecretName] =
105+
clientreg.KeycloakClientCredentialsSecretName(req.Namespace, resourceName)
106+
authbridgelog.Info("pre-populated Keycloak client credentials annotation",
107+
"namespace", req.Namespace, "name", resourceName,
108+
"secret", pod.Annotations[injector.AnnotationKeycloakClientSecretName])
109+
}
110+
90111
// Check if already injected (idempotency / reinvocation)
91112
if w.isAlreadyInjected(&pod.Spec) {
92113
// Reinvocation: sidecars exist but Keycloak Secret mounts may still be

0 commit comments

Comments
 (0)