From c9a74ec4809e829cf161d1ddd173b78c22da3f27 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 7 May 2026 19:26:31 -0400 Subject: [PATCH 1/2] feat(webhook): Pass keycloak_url + keycloak_realm to jwt-validation in synthesizePipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why When a namespace's `authbridge-runtime-config` ConfigMap has no `pipeline:` of its own, the webhook synthesizes one from the namespace's `authbridge-config` env-var contract (NamespaceConfig). The synthesis passed `keycloak_url` + `keycloak_realm` only to the outbound token-exchange plugin. jwt-validation got just `issuer`. kagenti-extensions#383 extends the jwt-validation plugin to accept these two fields and derive jwks_url from the INTERNAL Keycloak URL — the sidecar actually reaches the JWKS endpoint from inside the cluster, and `issuer` is the PUBLIC hostname (required for `iss`-claim matching but typically unreachable from inside the pod). Without the keycloak_* hints, jwt-validation falls back to issuer-derivation and every inbound request fails with "connection refused" fetching the JWKS → 401. ## Scope Pure addition to `synthesizePipeline`: the same two fields that already feed token-exchange now also feed jwt-validation. Both plugins end up with the same "where is Keycloak internally" hint, mirroring the pre-PR-#378 binary behavior where jwks_url was derived from outbound.token_url via a cross-plugin pass. Test: extend `TestEnsurePerAgentConfigMap_EmptyBaseYAML_Fallback FromNsConfig` to assert jwt-validation now receives keycloak_url and keycloak_realm. ## Compatibility Requires kagenti-extensions ≥ the tag that includes #383 — older authbridge binaries reject the new fields at DisallowUnknownFields decode. The chart in kagenti/kagenti#1507 bumps both pins together. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../internal/webhook/injector/pod_mutator.go | 20 +++++++++++++++++-- .../webhook/injector/pod_mutator_test.go | 10 ++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator.go b/kagenti-operator/internal/webhook/injector/pod_mutator.go index 590546e0..4cc83d2e 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator.go @@ -570,9 +570,16 @@ func perAgentConfigMapName(crName string) string { // // The synthesized shape matches what plugins expect: // - jwt-validation.config.issuer from NamespaceConfig.Issuer -// (rest of the plugin's config comes from its defaults — +// (for matching the JWT `iss` claim — the PUBLIC Keycloak URL in +// split-horizon deployments). keycloak_url + keycloak_realm are +// also passed through so the plugin can derive jwks_url from the +// INTERNAL URL — the sidecar actually GETs this URL from inside +// the cluster, and the public hostname typically won't resolve +// from inside the mesh. See kagenti-extensions#383 for why the +// jwt-validation plugin needs its own copy of these fields. +// Other plugin settings fall back to their own defaults — // audience_file=/shared/client-id.txt, bypass_paths=standard -// probes, jwks_url derived from issuer). +// probes. // - token-exchange.config with Keycloak URL/realm, default_policy, // and identity block keyed off ClientAuthType. File paths fall // through to plugin defaults so operators don't have to @@ -586,6 +593,15 @@ func synthesizePipeline(nsConfig *NamespaceConfig) map[string]interface{} { if nsConfig.Issuer != "" { jwtCfg["issuer"] = nsConfig.Issuer } + // Pass keycloak_url + keycloak_realm through to jwt-validation so + // it can derive jwks_url from the internal URL rather than the + // public issuer. Mirrors the pair already handed to token-exchange. + if nsConfig.KeycloakURL != "" { + jwtCfg["keycloak_url"] = nsConfig.KeycloakURL + } + if nsConfig.KeycloakRealm != "" { + jwtCfg["keycloak_realm"] = nsConfig.KeycloakRealm + } tokenCfg := map[string]interface{}{} if nsConfig.KeycloakURL != "" { diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go index 30e46bb2..ed16937e 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go @@ -1098,6 +1098,16 @@ func TestEnsurePerAgentConfigMap_EmptyBaseYAML_FallbackFromNsConfig(t *testing.T if got, want := jwtCfg["issuer"], "http://keycloak:8080/realms/kagenti"; got != want { t.Errorf("jwt-validation.config.issuer = %v, want %v", got, want) } + // keycloak_url + keycloak_realm are passed to jwt-validation so the + // plugin derives jwks_url from the internal URL. Required for + // split-horizon deployments where `issuer` (public) isn't reachable + // from inside the pod. See kagenti-extensions#383. + if got, want := jwtCfg["keycloak_url"], "http://keycloak:8080"; got != want { + t.Errorf("jwt-validation.config.keycloak_url = %v, want %v", got, want) + } + if got, want := jwtCfg["keycloak_realm"], "kagenti"; got != want { + t.Errorf("jwt-validation.config.keycloak_realm = %v, want %v", got, want) + } tokCfg := pluginConfigAt(t, cfg, "outbound", "token-exchange") if got, want := tokCfg["keycloak_url"], "http://keycloak:8080"; got != want { From eca52f0ab891f57017f80c4ea6c4b7a4eb7be5c7 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 7 May 2026 19:41:03 -0400 Subject: [PATCH 2/2] test: Fix TestClientRegistration_EndToEnd_CredentialsAuthenticate fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #321 moved keycloak-admin-secret reads to the OperatorNamespace (security hardening: agent namespaces should not hold realm admin credentials). The table-driven tests and the happy-path test were updated, but TestClientRegistration_EndToEnd_CredentialsAuthenticate was missed — it still seeds the secret in the agent namespace and constructs the reconciler with an empty OperatorNamespace. Result: post-merge CI on main and every downstream PR fails the same test with `result={false 30s }` — the reconciler can't find the admin secret in the (empty) operator namespace so it requeues after 30s, tripping the `res != ctrl.Result{}` assertion. Fix: seed the secret under clientRegistrationTestOperatorNS and set OperatorNamespace on the reconciler, matching the adjacent happy- path test at line 355. Not my PR's scope but it blocks CI and is a one-line structural fix symmetric with the sibling tests — bundling rather than waiting on a separate cleanup PR. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../controller/clientregistration_controller_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/kagenti-operator/internal/controller/clientregistration_controller_test.go b/kagenti-operator/internal/controller/clientregistration_controller_test.go index ef404587..646b1c41 100644 --- a/kagenti-operator/internal/controller/clientregistration_controller_test.go +++ b/kagenti-operator/internal/controller/clientregistration_controller_test.go @@ -425,10 +425,17 @@ func TestClientRegistration_EndToEnd_CredentialsAuthenticate(t *testing.T) { clusterFeatureGatesConfigMap(true), dep, authbridgeConfigMapForTest(clientRegistrationTestNamespace, idp.URL()), - keycloakAdminSecretForTest(clientRegistrationTestNamespace), + // keycloak-admin-secret lives in the operator namespace (not the + // agent namespace) after PR #321's security hardening. The + // reconciler reads it from there via OperatorNamespace. + keycloakAdminSecretForTest(clientRegistrationTestOperatorNS), ).Build() - r := &ClientRegistrationReconciler{Client: c, Scheme: scheme} + r := &ClientRegistrationReconciler{ + Client: c, + Scheme: scheme, + OperatorNamespace: clientRegistrationTestOperatorNS, + } res, err := r.Reconcile(ctx, req) if err != nil || res != (ctrl.Result{}) { t.Fatalf("Reconcile: result=%v err=%v", res, err)