diff --git a/kagenti-operator/api/v1alpha1/agentruntime_types.go b/kagenti-operator/api/v1alpha1/agentruntime_types.go index 7aa1d864..97e3e73c 100644 --- a/kagenti-operator/api/v1alpha1/agentruntime_types.go +++ b/kagenti-operator/api/v1alpha1/agentruntime_types.go @@ -133,6 +133,13 @@ type IdentitySpec struct { // SPIFFE specifies SPIFFE identity configuration overrides // +optional SPIFFE *SPIFFEIdentity `json:"spiffe,omitempty"` + + // AllowedAudiences specifies additional JWT audiences that the AuthProxy + // sidecar should accept for inbound requests. This is a transitional + // mechanism to support application-to-agent flows until the auth model + // is finalized. See https://github.com/kagenti/kagenti-operator/issues/368 + // +optional + AllowedAudiences []string `json:"allowedAudiences,omitempty"` } // SPIFFEIdentity configures SPIFFE workload identity for an AgentRuntime. diff --git a/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go b/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go index 1576f880..a1a66af6 100644 --- a/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/kagenti-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -500,6 +500,11 @@ func (in *IdentitySpec) DeepCopyInto(out *IdentitySpec) { *out = new(SPIFFEIdentity) **out = **in } + if in.AllowedAudiences != nil { + in, out := &in.AllowedAudiences, &out.AllowedAudiences + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IdentitySpec. diff --git a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml index 8d5fcb6b..547e88f5 100644 --- a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml +++ b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml @@ -97,6 +97,15 @@ spec: identity: description: Identity specifies optional per-workload identity overrides properties: + allowedAudiences: + description: |- + AllowedAudiences specifies additional JWT audiences that the AuthProxy + sidecar should accept for inbound requests. This is a transitional + mechanism to support application-to-agent flows until the auth model + is finalized. See https://github.com/kagenti/kagenti-operator/issues/368 + items: + type: string + type: array spiffe: description: SPIFFE specifies SPIFFE identity configuration overrides properties: diff --git a/kagenti-operator/internal/webhook/injector/agentruntime_config.go b/kagenti-operator/internal/webhook/injector/agentruntime_config.go index df02811e..4d6331fa 100644 --- a/kagenti-operator/internal/webhook/injector/agentruntime_config.go +++ b/kagenti-operator/internal/webhook/injector/agentruntime_config.go @@ -19,6 +19,7 @@ package injector import ( "context" "fmt" + "slices" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" "k8s.io/apimachinery/pkg/api/meta" @@ -44,6 +45,9 @@ type AgentRuntimeOverrides struct { AdminCredentialsSecretName *string AdminCredentialsSecretNamespace *string + // Identity — from .spec.identity.allowedAudiences + AllowedAudiences []string + // Observability — from .spec.trace TraceEndpoint *string TraceProtocol *string // "grpc" or "http" @@ -109,6 +113,11 @@ func extractOverrides(rt *agentv1alpha1.AgentRuntime) *AgentRuntimeOverrides { overrides.SpiffeTrustDomain = &td } + // .spec.identity.allowedAudiences — clone to decouple from CR memory + if rt.Spec.Identity != nil && len(rt.Spec.Identity.AllowedAudiences) > 0 { + overrides.AllowedAudiences = slices.Clone(rt.Spec.Identity.AllowedAudiences) + } + // .spec.trace.endpoint if rt.Spec.Trace != nil && rt.Spec.Trace.Endpoint != "" { ep := rt.Spec.Trace.Endpoint diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator.go b/kagenti-operator/internal/webhook/injector/pod_mutator.go index c957c1ef..f68dd214 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator.go @@ -19,6 +19,7 @@ package injector import ( "context" "fmt" + "slices" "github.com/kagenti/operator/internal/webhook/config" appsv1 "k8s.io/api/apps/v1" @@ -300,6 +301,14 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp } } + // ======================================== + // Resolve AllowedAudiences (from AgentRuntime CR) + // ======================================== + var allowedAudiences []string + if arOverrides != nil { + allowedAudiences = slices.Clone(arOverrides.AllowedAudiences) + } + if currentGates.PerWorkloadConfigResolution { // Resolved path: build literal env vars from namespace config // arOverrides was already read above as a gate check. @@ -490,7 +499,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp "reverse_proxy_backend": fmt.Sprintf("http://127.0.0.1:%d", newAgentPort), "forward_proxy_addr": fmt.Sprintf(":%d", forwardProxyPort), }, - mtlsMode) + mtlsMode, allowedAudiences) if err != nil { return false, fmt.Errorf("proxy-sidecar per-agent ConfigMap: %w", err) } @@ -566,7 +575,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp // we'd never reach this branch with a non-empty mtlsMode in practice; // passing "" here is the explicit-defense complement. perAgentCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, - ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil, "") + ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil, "", allowedAudiences) if err != nil { return false, fmt.Errorf("envoy-sidecar per-agent ConfigMap: %w", err) } @@ -743,6 +752,38 @@ func synthesizePipeline(nsConfig *NamespaceConfig) map[string]interface{} { } } +// injectAllowedAudiences walks into cfg["pipeline"]["inbound"]["plugins"] and sets +// allowed_audiences on the jwt-validation plugin's config block. If the pipeline +// structure does not contain a jwt-validation plugin, a warning is logged and the +// setting is silently dropped. +func injectAllowedAudiences(cfg map[string]interface{}, audiences []string) { + pipeline, _ := cfg["pipeline"].(map[string]interface{}) + if pipeline == nil { + mutatorLog.Info("WARN: allowedAudiences set on AgentRuntime CR but no pipeline section in config; setting has no effect") + return + } + inbound, _ := pipeline["inbound"].(map[string]interface{}) + if inbound == nil { + mutatorLog.Info("WARN: allowedAudiences set on AgentRuntime CR but no inbound pipeline; setting has no effect") + return + } + plugins, _ := inbound["plugins"].([]interface{}) + for _, p := range plugins { + pm, _ := p.(map[string]interface{}) + if pm == nil || pm["name"] != "jwt-validation" { + continue + } + pluginCfg, _ := pm["config"].(map[string]interface{}) + if pluginCfg == nil { + pluginCfg = map[string]interface{}{} + pm["config"] = pluginCfg + } + pluginCfg["allowed_audiences"] = audiences + return + } + mutatorLog.Info("WARN: allowedAudiences set on AgentRuntime CR but jwt-validation plugin not in inbound pipeline; setting has no effect") +} + // ensurePerAgentConfigMap creates or updates a per-agent ConfigMap that merges the // namespace-level authbridge-runtime-config with per-agent overrides (mode, listener // addresses, mtls). The authbridge sidecar mounts this instead of the shared ConfigMap. @@ -762,6 +803,7 @@ func (m *PodMutator) ensurePerAgentConfigMap( nsConfig *NamespaceConfig, listenerOverrides map[string]string, mtlsMode string, + allowedAudiences []string, ) (string, error) { cmName := perAgentConfigMapName(crName) @@ -796,6 +838,13 @@ func (m *PodMutator) ensurePerAgentConfigMap( cfg["pipeline"] = synthesizePipeline(nsConfig) } + // Inject per-agent AllowedAudiences into the jwt-validation plugin config. + // This overrides any namespace-level audience setting with the per-agent value + // from the AgentRuntime CR's .spec.identity.allowedAudiences. + if len(allowedAudiences) > 0 { + injectAllowedAudiences(cfg, allowedAudiences) + } + // Override mode cfg["mode"] = mode diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go index 3c037a7c..0e795d19 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go @@ -1242,7 +1242,7 @@ func TestEnsurePerAgentConfigMap_EmptyBaseYAML_FallbackFromNsConfig(t *testing.T } cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-service", - ModeProxySidecar, "", nsConfig, nil, "") + ModeProxySidecar, "", nsConfig, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1359,7 +1359,7 @@ pipeline: ` cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", - ModeEnvoySidecar, baseYAML, &NamespaceConfig{}, nil, "") + ModeEnvoySidecar, baseYAML, &NamespaceConfig{}, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1418,7 +1418,7 @@ pipeline: } cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", - ModeProxySidecar, baseYAML, &NamespaceConfig{}, overrides, "") + ModeProxySidecar, baseYAML, &NamespaceConfig{}, overrides, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1454,7 +1454,7 @@ func TestEnsurePerAgentConfigMap_ExistingCM_OwnedByWebhook_Updated(t *testing.T) ctx := context.Background() _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", - ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "") + ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1482,7 +1482,7 @@ func TestEnsurePerAgentConfigMap_ExistingCM_OverwrittenBySSA(t *testing.T) { ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", - ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "") + ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1510,7 +1510,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_SetFromDeployment(t *testing.T) ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-service", - ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "") + ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1537,7 +1537,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_SetFromStatefulSet(t *testing.T) ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-stateful-agent", - ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "") + ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1558,7 +1558,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_NoWorkload_Skipped(t *testing.T) ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "bare-pod-agent", - ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "") + ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1581,7 +1581,7 @@ func TestEnsurePerAgentConfigMap_FederatedJWT_MapsToSpiffe(t *testing.T) { } cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", - ModeEnvoySidecar, "", nsConfig, nil, "") + ModeEnvoySidecar, "", nsConfig, nil, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1623,7 +1623,7 @@ func TestEnsurePerAgentConfigMap_MTLSStrict_RendersBlock(t *testing.T) { ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "mtls-agent", - ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModeStrict) + ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModeStrict, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1654,7 +1654,7 @@ func TestEnsurePerAgentConfigMap_MTLSPermissive_RendersBlock(t *testing.T) { ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "mtls-agent", - ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModePermissive) + ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModePermissive, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1689,7 +1689,7 @@ func TestEnsurePerAgentConfigMap_MTLSDisabled_OmitsBlock(t *testing.T) { ctx := context.Background() cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-mtls-"+tt.name, - ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, tt.mtlsMode) + ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, tt.mtlsMode, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1717,7 +1717,7 @@ func TestEnsurePerAgentConfigMap_MTLSScrubsStaleBlock(t *testing.T) { baseYAML := "mode: proxy-sidecar\nmtls:\n mode: strict\n" cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "scrub-agent", - ModeProxySidecar, baseYAML, &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModeDisabled) + ModeProxySidecar, baseYAML, &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModeDisabled, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1729,3 +1729,114 @@ func TestEnsurePerAgentConfigMap_MTLSScrubsStaleBlock(t *testing.T) { t.Errorf("stale mtls block should be scrubbed when mtlsMode=disabled; got cfg=%+v", cfg) } } + +func TestEnsurePerAgentConfigMap_AllowedAudiences_InjectedIntoJWTValidation(t *testing.T) { + m := newTestMutator() + ctx := context.Background() + + nsConfig := &NamespaceConfig{ + Issuer: "http://keycloak:8080/realms/kagenti", + KeycloakURL: "http://keycloak:8080", + KeycloakRealm: "kagenti", + ClientAuthType: "client-secret", + } + + audiences := []string{"playground", "kagenti-agents"} + cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + ModeProxySidecar, "", nsConfig, nil, "", audiences) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cm := fetchConfigMap(t, m, "team1", cmName) + cfg := parseConfigYAML(t, cm) + + jwtCfg := pluginConfigAt(t, cfg, "inbound", "jwt-validation") + rawAud, ok := jwtCfg["allowed_audiences"] + if !ok { + t.Fatal("expected allowed_audiences in jwt-validation config") + } + audList, ok := rawAud.([]interface{}) + if !ok { + t.Fatalf("allowed_audiences is %T, want []interface{}", rawAud) + } + if len(audList) != 2 || audList[0] != "playground" || audList[1] != "kagenti-agents" { + t.Errorf("allowed_audiences = %v, want [playground kagenti-agents]", audList) + } +} + +func TestEnsurePerAgentConfigMap_AllowedAudiences_NilDoesNotInject(t *testing.T) { + m := newTestMutator() + ctx := context.Background() + + nsConfig := &NamespaceConfig{ + Issuer: "http://keycloak:8080/realms/kagenti", + KeycloakURL: "http://keycloak:8080", + KeycloakRealm: "kagenti", + ClientAuthType: "client-secret", + } + + cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + ModeProxySidecar, "", nsConfig, nil, "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cm := fetchConfigMap(t, m, "team1", cmName) + cfg := parseConfigYAML(t, cm) + + jwtCfg := pluginConfigAt(t, cfg, "inbound", "jwt-validation") + if _, ok := jwtCfg["allowed_audiences"]; ok { + t.Error("allowed_audiences should not be present when nil") + } +} + +func TestEnsurePerAgentConfigMap_AllowedAudiences_OverridesBaseYAML(t *testing.T) { + m := newTestMutator() + ctx := context.Background() + + // Base YAML has namespace-level audiences + baseYAML := ` +mode: proxy-sidecar +pipeline: + inbound: + plugins: + - name: jwt-validation + config: + issuer: "http://issuer" + allowed_audiences: + - namespace-aud + outbound: + plugins: + - name: token-exchange + config: + keycloak_url: "http://keycloak:8080" + keycloak_realm: "kagenti" + identity: + type: client-secret +` + + // AgentRuntime CR sets different audiences — AR must win + audiences := []string{"agent-aud"} + cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + ModeProxySidecar, baseYAML, &NamespaceConfig{}, nil, "", audiences) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cm := fetchConfigMap(t, m, "team1", cmName) + cfg := parseConfigYAML(t, cm) + + jwtCfg := pluginConfigAt(t, cfg, "inbound", "jwt-validation") + rawAud, ok := jwtCfg["allowed_audiences"] + if !ok { + t.Fatal("expected allowed_audiences in jwt-validation config") + } + audList, ok := rawAud.([]interface{}) + if !ok { + t.Fatalf("allowed_audiences is %T, want []interface{}", rawAud) + } + if len(audList) != 1 || audList[0] != "agent-aud" { + t.Errorf("allowed_audiences = %v, want [agent-aud] (AgentRuntime CR must override base YAML)", audList) + } +} diff --git a/kagenti-operator/internal/webhook/injector/resolved_config.go b/kagenti-operator/internal/webhook/injector/resolved_config.go index 222f7cf8..989c857b 100644 --- a/kagenti-operator/internal/webhook/injector/resolved_config.go +++ b/kagenti-operator/internal/webhook/injector/resolved_config.go @@ -39,6 +39,7 @@ type ResolvedConfig struct { TokenURL string Issuer string ExpectedAudience string + AllowedAudiences []string // from AgentRuntime .spec.identity.allowedAudiences or namespace CM TargetAudience string TargetScopes string DefaultOutboundPolicy string @@ -97,6 +98,9 @@ func ResolveConfig(platform *config.PlatformConfig, ns *NamespaceConfig, ar *Age // Apply AgentRuntime overrides (highest precedence) if ar != nil { + if len(ar.AllowedAudiences) > 0 { + resolved.AllowedAudiences = ar.AllowedAudiences + } if ar.SpiffeTrustDomain != nil { resolved.SpiffeTrustDomain = *ar.SpiffeTrustDomain }