From c8b4bdccc153727217cd5f65cecbce697a05b9a6 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Mon, 12 Jan 2026 14:40:05 +0530 Subject: [PATCH 01/13] feat(securitypolicy): add MergeType support for policy merging Add MergeType field to SecurityPolicy to enable policy merging similar to BackendTrafficPolicy. This allows route-level policies to merge with parent Gateway/Listener policies rather than completely overriding them. Fixes #6734 Signed-off-by: Rajat Vig --- api/v1alpha1/securitypolicy_types.go | 10 + api/v1alpha1/zz_generated.deepcopy.go | 5 + internal/gatewayapi/securitypolicy.go | 287 ++++++++++++++---- internal/gatewayapi/securitypolicy_test.go | 123 +++++++- site/content/en/latest/api/extension_types.md | 2 + 5 files changed, 371 insertions(+), 56 deletions(-) diff --git a/api/v1alpha1/securitypolicy_types.go b/api/v1alpha1/securitypolicy_types.go index 08cb1e63c6..811223ca90 100644 --- a/api/v1alpha1/securitypolicy_types.go +++ b/api/v1alpha1/securitypolicy_types.go @@ -52,6 +52,16 @@ type SecurityPolicy struct { type SecurityPolicySpec struct { PolicyTargetReferences `json:",inline"` + // MergeType determines how this configuration is merged with existing SecurityPolicy + // configurations targeting a parent resource. When set, this configuration will be merged + // into a parent SecurityPolicy (i.e. the one targeting a Gateway or Listener). + // This field cannot be set when targeting a parent resource (Gateway). + // If unset, no merging occurs, and only the most specific configuration takes effect. + // + // +kubebuilder:validation:XValidation:rule="!has(self.mergeType) || (has(self.targetRef) && self.targetRef.kind != 'Gateway') || (has(self.targetRefs) && self.targetRefs.all(ref, ref.kind != 'Gateway'))", message="mergeType cannot be set when targeting a Gateway" + // +optional + MergeType *MergeType `json:"mergeType,omitempty"` + // APIKeyAuth defines the configuration for the API Key Authentication. // // +optional diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ad86318e8e..919d6a9b43 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -6748,6 +6748,11 @@ func (in *SecurityPolicyList) DeepCopyObject() runtime.Object { func (in *SecurityPolicySpec) DeepCopyInto(out *SecurityPolicySpec) { *out = *in in.PolicyTargetReferences.DeepCopyInto(&out.PolicyTargetReferences) + if in.MergeType != nil { + in, out := &in.MergeType, &out.MergeType + *out = new(MergeType) + **out = **in + } if in.APIKeyAuth != nil { in, out := &in.APIKeyAuth, &out.APIKeyAuth *out = new(APIKeyAuth) diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index a493ae81a0..ed3a44cafb 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -107,16 +107,31 @@ func (t *Translator) ProcessSecurityPolicies( // Map of Gateway to the routes attached to it. // The routes are grouped by sectionNames of their targetRefs - gatewayRouteMap := make(map[string]map[string]sets.Set[string], gatewayMapSize) + gatewayRouteMap := &GatewayPolicyRouteMap{ + Routes: make(map[NamespacedNameWithSection]sets.Set[string], gatewayMapSize), + SectionIndex: make(map[types.NamespacedName]sets.Set[string], gatewayMapSize), + } handledPolicies := make(map[types.NamespacedName]*egv1a1.SecurityPolicy, policyMapSize) + // Map of attached Policy to Gateway. Used for policy merge process. + gatewayPolicyMap := make(map[NamespacedNameWithSection]*egv1a1.SecurityPolicy, gatewayMapSize) + + // Map of Gateway to the routes merged to it. + gatewayPolicyMerged := &GatewayPolicyRouteMap{ + Routes: make(map[NamespacedNameWithSection]sets.Set[string], gatewayMapSize), + SectionIndex: make(map[types.NamespacedName]sets.Set[string], gatewayMapSize), + } + // Translate // 1. First translate Policies targeting RouteRules // 2. Next translate Policies targeting xRoutes // 3. Then translate Policies targeting Listeners // 4. Finally, the policies targeting Gateways + // Build gateway policy maps, which are needed when processing the policies targeting xRoutes. + t.buildGatewayPolicyMapForSecurity(securityPolicies, gateways, gatewayMap, gatewayPolicyMap) + // Process the policies targeting RouteRules (HTTP + TCP) for _, currPolicy := range securityPolicies { policyName := utils.NamespacedName(currPolicy) @@ -131,8 +146,7 @@ func (t *Translator) ProcessSecurityPolicies( res = append(res, policy) } - t.processSecurityPolicyForRoute(resources, xdsIR, - routeMap, gatewayRouteMap, policy, currTarget) + t.processSecurityPolicyForRoute(resources, xdsIR, routeMap, gatewayRouteMap, gatewayPolicyMerged, gatewayPolicyMap, policy, currTarget) } } } @@ -150,8 +164,7 @@ func (t *Translator) ProcessSecurityPolicies( res = append(res, policy) } - t.processSecurityPolicyForRoute(resources, xdsIR, - routeMap, gatewayRouteMap, policy, currTarget) + t.processSecurityPolicyForRoute(resources, xdsIR, routeMap, gatewayRouteMap, gatewayPolicyMerged, gatewayPolicyMap, policy, currTarget) } } } @@ -169,8 +182,7 @@ func (t *Translator) ProcessSecurityPolicies( res = append(res, policy) } - t.processSecurityPolicyForGateway(resources, xdsIR, - gatewayMap, gatewayRouteMap, policy, currTarget) + t.processSecurityPolicyForGateway(resources, xdsIR, gatewayMap, gatewayRouteMap, gatewayPolicyMerged, policy, currTarget) } } } @@ -188,8 +200,7 @@ func (t *Translator) ProcessSecurityPolicies( res = append(res, policy) } - t.processSecurityPolicyForGateway(resources, xdsIR, - gatewayMap, gatewayRouteMap, policy, currTarget) + t.processSecurityPolicyForGateway(resources, xdsIR, gatewayMap, gatewayRouteMap, gatewayPolicyMerged, policy, currTarget) } } } @@ -203,18 +214,65 @@ func (t *Translator) ProcessSecurityPolicies( return res } +func (t *Translator) buildGatewayPolicyMapForSecurity( + securityPolicies []*egv1a1.SecurityPolicy, + gateways []*GatewayContext, + gatewayMap map[types.NamespacedName]*policyGatewayTargetContext, + gatewayPolicyMap map[NamespacedNameWithSection]*egv1a1.SecurityPolicy, +) { + for _, currPolicy := range securityPolicies { + targetRefs := getPolicyTargetRefs(currPolicy.Spec.PolicyTargetReferences, gateways, currPolicy.Namespace) + for _, currTarget := range targetRefs { + if currTarget.Kind == resource.KindGateway { + // Check if the gateway exists + key := types.NamespacedName{ + Name: string(currTarget.Name), + Namespace: currPolicy.Namespace, + } + gateway, ok := gatewayMap[key] + if !ok { + continue + } + + // Check if the specified listener exists when sectionName is set + if currTarget.SectionName != nil { + if err := validateGatewayListenerSectionName( + *currTarget.SectionName, + key, + gateway.listeners, + ); err != nil { + continue + } + } + + mapKey := NamespacedNameWithSection{ + NamespacedName: key, + SectionName: ptr.Deref(currTarget.SectionName, ""), + } + + // Only store the first policy for this Gateway/Listener - conflicts are handled elsewhere + if _, ok := gatewayPolicyMap[mapKey]; ok { + continue + } + gatewayPolicyMap[mapKey] = currPolicy + } + } + } +} + func (t *Translator) processSecurityPolicyForRoute( resources *resource.Resources, xdsIR resource.XdsIRMap, routeMap map[policyTargetRouteKey]*policyRouteTargetContext, - gatewayRouteMap map[string]map[string]sets.Set[string], + gatewayRouteMap *GatewayPolicyRouteMap, + gatewayPolicyMerged *GatewayPolicyRouteMap, + gatewayPolicyMap map[NamespacedNameWithSection]*egv1a1.SecurityPolicy, policy *egv1a1.SecurityPolicy, currTarget gwapiv1.LocalPolicyTargetReferenceWithSectionName, ) { var ( - targetedRoute RouteContext - parentGateways []*gwapiv1.ParentReference - resolveErr *status.PolicyResolveError + targetedRoute RouteContext + resolveErr *status.PolicyResolveError ) targetedRoute, resolveErr = resolveSecurityPolicyRouteTargetRef(policy, currTarget, routeMap) @@ -230,39 +288,49 @@ func (t *Translator) processSecurityPolicyForRoute( // gatewayRouteMap, which will be used to check policy override. // The parent gateways are also used to set the status of the policy. parentRefs := GetParentReferences(targetedRoute) + ancestorRefs := make([]*gwapiv1.ParentReference, 0, len(parentRefs)) + parentRefCtxs := make([]*RouteParentContext, 0, len(parentRefs)) for _, p := range parentRefs { if p.Kind == nil || *p.Kind == resource.KindGateway { namespace := targetedRoute.GetNamespace() if p.Namespace != nil { namespace = string(*p.Namespace) } - gwNN := types.NamespacedName{ - Namespace: namespace, - Name: string(p.Name), + mapKey := NamespacedNameWithSection{ + NamespacedName: types.NamespacedName{ + Namespace: namespace, + Name: string(p.Name), + }, + SectionName: ptr.Deref(p.SectionName, ""), } - key := gwNN.String() - if _, ok := gatewayRouteMap[key]; !ok { - gatewayRouteMap[key] = make(map[string]sets.Set[string]) + if _, ok := gatewayRouteMap.Routes[mapKey]; !ok { + gatewayRouteMap.Routes[mapKey] = make(sets.Set[string]) } - listenerRouteMap := gatewayRouteMap[key] - sectionName := "" - if p.SectionName != nil { - sectionName = string(*p.SectionName) + gatewayRouteMap.Routes[mapKey].Insert(utils.NamespacedName(targetedRoute).String()) + + // Register section name to Gateway index for efficient lookup when retrieving overridden and merged targets + if _, ok := gatewayRouteMap.SectionIndex[mapKey.NamespacedName]; !ok { + gatewayRouteMap.SectionIndex[mapKey.NamespacedName] = make(sets.Set[string]) } - if _, ok := listenerRouteMap[sectionName]; !ok { - listenerRouteMap[sectionName] = make(sets.Set[string]) + gatewayRouteMap.SectionIndex[mapKey.NamespacedName].Insert(string(mapKey.SectionName)) + + // Do need a section name since the policy is targeting to a route. + ancestorRef := getAncestorRefForPolicy(mapKey.NamespacedName, p.SectionName) + ancestorRefs = append(ancestorRefs, &ancestorRef) + + // Only process parentRefs that were handled by this translator + // (skip those referencing Gateways with different GatewayClasses) + if parentRefCtx := targetedRoute.GetRouteParentContext(p); parentRefCtx != nil { + parentRefCtxs = append(parentRefCtxs, parentRefCtx) } - listenerRouteMap[sectionName].Insert(utils.NamespacedName(targetedRoute).String()) - ancestorRef := getAncestorRefForPolicy(gwNN, p.SectionName) - parentGateways = append(parentGateways, &ancestorRef) } } // Set conditions for resolve error, then skip current xroute if resolveErr != nil { status.SetResolveErrorForPolicyAncestors(&policy.Status, - parentGateways, + ancestorRefs, t.GatewayControllerName, policy.Generation, resolveErr, @@ -281,7 +349,7 @@ func (t *Translator) processSecurityPolicyForRoute( } if err := validator(policy); err != nil { status.SetTranslationErrorForPolicyAncestors(&policy.Status, - parentGateways, + ancestorRefs, t.GatewayControllerName, policy.Generation, status.Error2ConditionMsg(fmt.Errorf("%s: %w", errMsg, err)), @@ -290,21 +358,115 @@ func (t *Translator) processSecurityPolicyForRoute( return } - if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR); err != nil { - status.SetTranslationErrorForPolicyAncestors(&policy.Status, - parentGateways, - t.GatewayControllerName, - policy.Generation, - status.Error2ConditionMsg(err), - ) + // Check if merging is enabled + if policy.Spec.MergeType == nil { + // No merging - use existing translation logic + if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR); err != nil { + status.SetTranslationErrorForPolicyAncestors(&policy.Status, + ancestorRefs, + t.GatewayControllerName, + policy.Generation, + status.Error2ConditionMsg(err), + ) + } + } else { + // Merging enabled - merge with parent policies + for _, parentRefCtx := range parentRefCtxs { + for _, listener := range parentRefCtx.listeners { + gwNN := utils.NamespacedName(listener.gateway.Gateway) + ancestorRef := getAncestorRefForPolicy(gwNN, &listener.Name) + + // Find Gateway listener level policy + listenerMapKey := NamespacedNameWithSection{ + NamespacedName: gwNN, + SectionName: listener.Name, + } + listenerPolicy := gatewayPolicyMap[listenerMapKey] + + // Find Gateway level policy + gwMapKey := NamespacedNameWithSection{ + NamespacedName: gwNN, + } + gwPolicy := gatewayPolicyMap[gwMapKey] + + if gwPolicy == nil && listenerPolicy == nil { + // No parent policy found, fall back to current policy + if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR); err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + status.Error2ConditionMsg(err), + policy.Generation, + ) + } + continue + } + + parentPolicy := gwPolicy + if listenerPolicy != nil { + parentPolicy = listenerPolicy + } + + // Merge with parent policy + mergedPolicy, err := mergeSecurityPolicy(policy, parentPolicy) + if err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + fmt.Sprintf("error merging policies: %v", err), + policy.Generation, + ) + continue + } + + // Apply merged policy + if err := t.translateSecurityPolicyForRoute(mergedPolicy, targetedRoute, currTarget, resources, xdsIR); err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + status.Error2ConditionMsg(err), + policy.Generation, + ) + continue + } + + // Record the merged routes for gateway + if _, ok := gatewayPolicyMerged.Routes[listenerMapKey]; !ok { + gatewayPolicyMerged.Routes[listenerMapKey] = make(sets.Set[string]) + } + gatewayPolicyMerged.Routes[listenerMapKey].Insert(utils.NamespacedName(targetedRoute).String()) + + // Register section name to Gateway index + if _, ok := gatewayPolicyMerged.SectionIndex[listenerMapKey.NamespacedName]; !ok { + gatewayPolicyMerged.SectionIndex[listenerMapKey.NamespacedName] = make(sets.Set[string]) + } + gatewayPolicyMerged.SectionIndex[listenerMapKey.NamespacedName].Insert(string(listenerMapKey.SectionName)) + + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + egv1a1.PolicyConditionMerged, + metav1.ConditionTrue, + egv1a1.PolicyReasonMerged, + fmt.Sprintf("Merged with policy %s/%s", parentPolicy.Namespace, parentPolicy.Name), + policy.Generation, + ) + } + } } // Set Accepted condition if it is unset - status.SetAcceptedForPolicyAncestors(&policy.Status, parentGateways, t.GatewayControllerName, policy.Generation) + status.SetAcceptedForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, policy.Generation) // Check for deprecated fields and set warning if any are found if deprecatedFields := deprecatedFieldsUsedInSecurityPolicy(policy); len(deprecatedFields) > 0 { - status.SetDeprecatedFieldsWarningForPolicyAncestors(&policy.Status, parentGateways, t.GatewayControllerName, policy.Generation, deprecatedFields) + status.SetDeprecatedFieldsWarningForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, policy.Generation, deprecatedFields) } // Check if this policy is overridden by other policies targeting at route rule levels @@ -316,7 +478,7 @@ func (t *Translator) processSecurityPolicyForRoute( overriddenTargetsMessage := getOverriddenTargetsMessageForRoute(routeMap[key], currTarget.SectionName) if overriddenTargetsMessage != "" { status.SetConditionForPolicyAncestors(&policy.Status, - parentGateways, + ancestorRefs, t.GatewayControllerName, egv1a1.PolicyConditionOverridden, metav1.ConditionTrue, @@ -331,7 +493,8 @@ func (t *Translator) processSecurityPolicyForGateway( resources *resource.Resources, xdsIR resource.XdsIRMap, gatewayMap map[types.NamespacedName]*policyGatewayTargetContext, - gatewayRouteMap map[string]map[string]sets.Set[string], + gatewayRouteMap *GatewayPolicyRouteMap, + gatewayPolicyMergedMap *GatewayPolicyRouteMap, policy *egv1a1.SecurityPolicy, currTarget gwapiv1.LocalPolicyTargetReferenceWithSectionName, ) { @@ -351,23 +514,23 @@ func (t *Translator) processSecurityPolicyForGateway( // Find its ancestor reference by resolved gateway, even with resolve error gatewayNN := utils.NamespacedName(targetedGateway) - parentGateway := getAncestorRefForPolicy(gatewayNN, currTarget.SectionName) + ancestorRef := getAncestorRefForPolicy(gatewayNN, currTarget.SectionName) // Set conditions for resolve error, then skip current gateway if resolveErr != nil { status.SetResolveErrorForPolicyAncestor(&policy.Status, - &parentGateway, + &ancestorRef, t.GatewayControllerName, policy.Generation, resolveErr, ) - return } + // Set conditions for translation error if it got any if err := t.translateSecurityPolicyForGateway(policy, targetedGateway, currTarget, resources, xdsIR); err != nil { status.SetTranslationErrorForPolicyAncestor(&policy.Status, - &parentGateway, + &ancestorRef, t.GatewayControllerName, policy.Generation, status.Error2ConditionMsg(err), @@ -375,19 +538,30 @@ func (t *Translator) processSecurityPolicyForGateway( } // Set Accepted condition if it is unset - status.SetAcceptedForPolicyAncestor(&policy.Status, &parentGateway, t.GatewayControllerName, policy.Generation) + status.SetAcceptedForPolicyAncestor(&policy.Status, &ancestorRef, t.GatewayControllerName, policy.Generation) - // Check if this policy is overridden by other policies targeting at route and listener levels - overriddenTargetsMessage := getOverriddenTargetsMessageForGateway( - gatewayMap[gatewayNN], gatewayRouteMap[gatewayNN.String()], currTarget.SectionName) - if overriddenTargetsMessage != "" { + overriddenMessage, mergedMessage := getOverriddenAndMergedTargetsMessageForGateway( + gatewayMap[gatewayNN], gatewayRouteMap, gatewayPolicyMergedMap, currTarget.SectionName) + + if mergedMessage != "" { status.SetConditionForPolicyAncestor(&policy.Status, - &parentGateway, + &ancestorRef, + t.GatewayControllerName, + egv1a1.PolicyConditionMerged, + metav1.ConditionTrue, + egv1a1.PolicyReasonMerged, + "This policy is being merged by other securityPolicies for "+mergedMessage, + policy.Generation, + ) + } + if overriddenMessage != "" { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, t.GatewayControllerName, egv1a1.PolicyConditionOverridden, metav1.ConditionTrue, egv1a1.PolicyReasonOverridden, - "This policy is being overridden by other securityPolicies for "+overriddenTargetsMessage, + "This policy is being overridden by other securityPolicies for "+overriddenMessage, policy.Generation, ) } @@ -2115,3 +2289,12 @@ func defaultAuthorizationRuleName(policy *egv1a1.SecurityPolicy, index int) stri irConfigName(policy), strconv.Itoa(index)) } + +// mergeSecurityPolicy merges a route-level SecurityPolicy with a parent (Gateway/Listener) SecurityPolicy. +func mergeSecurityPolicy(routePolicy, parentPolicy *egv1a1.SecurityPolicy) (*egv1a1.SecurityPolicy, error) { + if routePolicy.Spec.MergeType == nil || parentPolicy == nil { + return routePolicy, nil + } + + return utils.Merge[*egv1a1.SecurityPolicy](parentPolicy, routePolicy, *routePolicy.Spec.MergeType) +} diff --git a/internal/gatewayapi/securitypolicy_test.go b/internal/gatewayapi/securitypolicy_test.go index c0261c0837..f4f8bc42b1 100644 --- a/internal/gatewayapi/securitypolicy_test.go +++ b/internal/gatewayapi/securitypolicy_test.go @@ -937,14 +937,22 @@ func Test_SecurityPolicy_TCP_Invalid_setsStatus_and_returns(t *testing.T) { } routeMap[key] = &policyRouteTargetContext{RouteContext: tcpRoute} - gatewayRouteMap := make(map[string]map[string]sets.Set[string]) + gatewayRouteMap := &GatewayPolicyRouteMap{ + Routes: make(map[NamespacedNameWithSection]sets.Set[string]), + SectionIndex: make(map[types.NamespacedName]sets.Set[string]), + } resources := resource.NewResources() xdsIR := make(resource.XdsIRMap) trContext.SetServices(resources.Services) tr.TranslatorContext = trContext // Process the policy - this should set error status - tr.processSecurityPolicyForRoute(resources, xdsIR, routeMap, gatewayRouteMap, policy, target) + gatewayPolicyMap := make(map[NamespacedNameWithSection]*egv1a1.SecurityPolicy) + gatewayPolicyMerged := &GatewayPolicyRouteMap{ + Routes: make(map[NamespacedNameWithSection]sets.Set[string]), + SectionIndex: make(map[types.NamespacedName]sets.Set[string]), + } + tr.processSecurityPolicyForRoute(resources, xdsIR, routeMap, gatewayRouteMap, gatewayPolicyMerged, gatewayPolicyMap, policy, target) // Assert that the policy has a False condition (error was set) require.True(t, hasParentFalseCondition(policy)) @@ -1014,14 +1022,22 @@ func Test_SecurityPolicy_HTTP_Invalid_setsStatus_and_returns(t *testing.T) { } routeMap[key] = &policyRouteTargetContext{RouteContext: httpRoute} - gatewayRouteMap := make(map[string]map[string]sets.Set[string]) + gatewayRouteMap := &GatewayPolicyRouteMap{ + Routes: make(map[NamespacedNameWithSection]sets.Set[string]), + SectionIndex: make(map[types.NamespacedName]sets.Set[string]), + } resources := resource.NewResources() xdsIR := make(resource.XdsIRMap) trContext.SetServices(resources.Services) tr.TranslatorContext = trContext // Process the policy - this should set error status - tr.processSecurityPolicyForRoute(resources, xdsIR, routeMap, gatewayRouteMap, policy, target) + gatewayPolicyMap := make(map[NamespacedNameWithSection]*egv1a1.SecurityPolicy) + gatewayPolicyMerged := &GatewayPolicyRouteMap{ + Routes: make(map[NamespacedNameWithSection]sets.Set[string]), + SectionIndex: make(map[types.NamespacedName]sets.Set[string]), + } + tr.processSecurityPolicyForRoute(resources, xdsIR, routeMap, gatewayRouteMap, gatewayPolicyMerged, gatewayPolicyMap, policy, target) // Assert that the policy has a False condition (error was set) require.True(t, hasParentFalseCondition(policy)) @@ -1371,3 +1387,102 @@ func Test_buildContextExtensions(t *testing.T) { }) } } + +func TestMergeSecurityPolicy(t *testing.T) { + tests := []struct { + name string + routePolicy *egv1a1.SecurityPolicy + parentPolicy *egv1a1.SecurityPolicy + wantSpec egv1a1.SecurityPolicySpec + wantErr bool + }{ + { + name: "merge with StrategicMerge - different fields", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + }, + }, + parentPolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + }, + }, + wantSpec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + }, + }, + { + name: "no merge when MergeType is nil", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + }, + }, + parentPolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + }, + }, + wantSpec: egv1a1.SecurityPolicySpec{ + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + }, + }, + { + name: "no merge when parentPolicy is nil", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + }, + }, + parentPolicy: nil, + wantSpec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := mergeSecurityPolicy(tt.routePolicy, tt.parentPolicy) + if (err != nil) != tt.wantErr { + t.Errorf("mergeSecurityPolicy() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + // Compare individual fields + require.Equal(t, tt.wantSpec.MergeType, got.Spec.MergeType, "MergeType should match") + require.Equal(t, tt.wantSpec.JWT, got.Spec.JWT, "JWT should match") + require.Equal(t, tt.wantSpec.BasicAuth, got.Spec.BasicAuth, "BasicAuth should match") + } + }) + } +} diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 5fa0a5fac2..e4c7284a8c 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -3477,6 +3477,7 @@ MergeType defines the type of merge operation _Appears in:_ - [BackendTrafficPolicySpec](#backendtrafficpolicyspec) - [KubernetesPatchSpec](#kubernetespatchspec) +- [SecurityPolicySpec](#securitypolicyspec) | Value | Description | | ----- | ----------- | @@ -4894,6 +4895,7 @@ _Appears in:_ | `targetRef` | _[LocalPolicyTargetReferenceWithSectionName](#localpolicytargetreferencewithsectionname)_ | true | | TargetRef is the name of the resource this policy is being attached to.
This policy and the TargetRef MUST be in the same namespace for this
Policy to have effect
Deprecated: use targetRefs/targetSelectors instead | | `targetRefs` | _LocalPolicyTargetReferenceWithSectionName array_ | true | | TargetRefs are the names of the Gateway resources this policy
is being attached to. | | `targetSelectors` | _[TargetSelector](#targetselector) array_ | true | | TargetSelectors allow targeting resources for this policy based on labels | +| `mergeType` | _[MergeType](#mergetype)_ | false | | MergeType determines how this configuration is merged with existing SecurityPolicy
configurations targeting a parent resource. When set, this configuration will be merged
into a parent SecurityPolicy (i.e. the one targeting a Gateway or Listener).
This field cannot be set when targeting a parent resource (Gateway).
If unset, no merging occurs, and only the most specific configuration takes effect. | | `apiKeyAuth` | _[APIKeyAuth](#apikeyauth)_ | false | | APIKeyAuth defines the configuration for the API Key Authentication. | | `cors` | _[CORS](#cors)_ | false | | CORS defines the configuration for Cross-Origin Resource Sharing (CORS). | | `basicAuth` | _[BasicAuth](#basicauth)_ | false | | BasicAuth defines the configuration for the HTTP Basic Authentication. | From d1d61ae1d099473b3bb0a858b0c5845d3b236614 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Tue, 13 Jan 2026 11:34:59 +0530 Subject: [PATCH 02/13] chore: drop unusable annotations and add in generatedfiles Signed-off-by: Rajat Vig --- api/v1alpha1/securitypolicy_types.go | 1 - .../generated/gateway.envoyproxy.io_securitypolicies.yaml | 8 ++++++++ .../generated/gateway.envoyproxy.io_securitypolicies.yaml | 8 ++++++++ test/helm/gateway-crds-helm/all.out.yaml | 8 ++++++++ test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml | 8 ++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/securitypolicy_types.go b/api/v1alpha1/securitypolicy_types.go index 811223ca90..bd866d60a8 100644 --- a/api/v1alpha1/securitypolicy_types.go +++ b/api/v1alpha1/securitypolicy_types.go @@ -58,7 +58,6 @@ type SecurityPolicySpec struct { // This field cannot be set when targeting a parent resource (Gateway). // If unset, no merging occurs, and only the most specific configuration takes effect. // - // +kubebuilder:validation:XValidation:rule="!has(self.mergeType) || (has(self.targetRef) && self.targetRef.kind != 'Gateway') || (has(self.targetRefs) && self.targetRefs.all(ref, ref.kind != 'Gateway'))", message="mergeType cannot be set when targeting a Gateway" // +optional MergeType *MergeType `json:"mergeType,omitempty"` diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml index 856b1d5dc6..3cb490f385 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml @@ -4294,6 +4294,14 @@ spec: required: - providers type: object + mergeType: + description: |- + MergeType determines how this configuration is merged with existing SecurityPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into a parent SecurityPolicy (i.e. the one targeting a Gateway or Listener). + This field cannot be set when targeting a parent resource (Gateway). + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string oidc: description: OIDC defines the configuration for the OpenID Connect (OIDC) authentication. diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml index 19431df117..cee7293e24 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml @@ -4293,6 +4293,14 @@ spec: required: - providers type: object + mergeType: + description: |- + MergeType determines how this configuration is merged with existing SecurityPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into a parent SecurityPolicy (i.e. the one targeting a Gateway or Listener). + This field cannot be set when targeting a parent resource (Gateway). + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string oidc: description: OIDC defines the configuration for the OpenID Connect (OIDC) authentication. diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 07475afa8c..cb0ae41603 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -49897,6 +49897,14 @@ spec: required: - providers type: object + mergeType: + description: |- + MergeType determines how this configuration is merged with existing SecurityPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into a parent SecurityPolicy (i.e. the one targeting a Gateway or Listener). + This field cannot be set when targeting a parent resource (Gateway). + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string oidc: description: OIDC defines the configuration for the OpenID Connect (OIDC) authentication. diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index ab7abe26f2..59cf8fe69c 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -29077,6 +29077,14 @@ spec: required: - providers type: object + mergeType: + description: |- + MergeType determines how this configuration is merged with existing SecurityPolicy + configurations targeting a parent resource. When set, this configuration will be merged + into a parent SecurityPolicy (i.e. the one targeting a Gateway or Listener). + This field cannot be set when targeting a parent resource (Gateway). + If unset, no merging occurs, and only the most specific configuration takes effect. + type: string oidc: description: OIDC defines the configuration for the OpenID Connect (OIDC) authentication. From edff170b2e0329f1e5ed46c5d0a6adeb77fa7100 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Tue, 13 Jan 2026 11:38:45 +0530 Subject: [PATCH 03/13] chore: add release notes Signed-off-by: Rajat Vig --- release-notes/current.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/release-notes/current.yaml b/release-notes/current.yaml index f99360ba04..507517e2f5 100644 --- a/release-notes/current.yaml +++ b/release-notes/current.yaml @@ -24,6 +24,7 @@ new features: | Added support for URLRewrite filter on individual backendRefs. Added support for custom headers on OTLP exports (metrics, tracing, access logs). Added support for configuring minimum response size for compression via minContentLength field in BackendTrafficPolicy. + Added support for MergeType in SecurityPolicy to enable route-level policies to merge with parent Gateway/Listener policies, similar to BackendTrafficPolicy. bug fixes: | Fixed configured OIDC authorization endpoint being overridden by discovered endpoints from issuer's well-known URL. From 8792f98d5444523629be0a3603a908b5f38d53bc Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Tue, 13 Jan 2026 11:58:30 +0530 Subject: [PATCH 04/13] test: add e2e test Signed-off-by: Rajat Vig --- test/e2e/testdata/securitypolicy-merged.yaml | 81 ++++++++++++++ test/e2e/tests/securitypolicy_merged.go | 111 +++++++++++++++++++ test/e2e/tests/utils.go | 36 ++++++ 3 files changed, 228 insertions(+) create mode 100644 test/e2e/testdata/securitypolicy-merged.yaml create mode 100644 test/e2e/tests/securitypolicy_merged.go diff --git a/test/e2e/testdata/securitypolicy-merged.yaml b/test/e2e/testdata/securitypolicy-merged.yaml new file mode 100644 index 0000000000..bebbfa3dd3 --- /dev/null +++ b/test/e2e/testdata/securitypolicy-merged.yaml @@ -0,0 +1,81 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: sp-merged + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: listener-1 + hostname: "listener1.merged.example.com" + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: sp-merged-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: sp-merged + sectionName: listener-1 + hostnames: ["listener1.merged.example.com"] + rules: + - matches: + - path: + value: "/merged" + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: v1 +kind: Secret +metadata: + name: basic-auth-merged-secret + namespace: gateway-conformance-infra +type: kubernetes.io/basic-auth +stringData: + .htpasswd: | + user1:$apr1$F3qTlsKs$AHIXMpXPDKVJw/AXaXAKQ/ +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: SecurityPolicy +metadata: + name: sp-merged-gateway + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: sp-merged + sectionName: listener-1 + basicAuth: + users: + name: basic-auth-merged-secret +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: SecurityPolicy +metadata: + name: sp-merged-route + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: sp-merged-route + mergeType: StrategicMerge + cors: + allowOrigins: + - "https://www.example.com" + - "https://www.foo.com" + allowMethods: + - GET + - POST + allowHeaders: + - "x-header-1" + - "x-header-2" diff --git a/test/e2e/tests/securitypolicy_merged.go b/test/e2e/tests/securitypolicy_merged.go new file mode 100644 index 0000000000..18a6722229 --- /dev/null +++ b/test/e2e/tests/securitypolicy_merged.go @@ -0,0 +1,111 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e + +package tests + +import ( + "net/http" + "testing" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + httputils "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + + "github.com/envoyproxy/gateway/internal/gatewayapi" + "github.com/envoyproxy/gateway/internal/gatewayapi/resource" +) + +func init() { + ConformanceTests = append(ConformanceTests, SecurityPolicyMergedTest) +} + +var SecurityPolicyMergedTest = suite.ConformanceTest{ + ShortName: "SecurityPolicyMerged", + Description: "Test section level policy attach and merged parent policy for SecurityPolicy", + Manifests: []string{"testdata/securitypolicy-merged.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("SecurityPolicyMerged", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "sp-merged-route", Namespace: ns} + gwNN := types.NamespacedName{Name: "sp-merged", Namespace: ns} + + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, + suite.Client, + suite.TimeoutConfig, + suite.ControllerName, + kubernetes.NewGatewayRef(gwNN), + routeNN, + ) + + ancestorRef := gwapiv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + SectionName: ptr.To(gwapiv1.SectionName("listener-1")), + } + + // Verify gateway-level policy is accepted + SecurityPolicyMustBeAccepted(t, + suite.Client, + types.NamespacedName{Name: "sp-merged-gateway", Namespace: ns}, + suite.ControllerName, + ancestorRef, + ) + + // Verify route-level policy has Merged condition + SecurityPolicyMustBeMerged(t, + suite.Client, + types.NamespacedName{Name: "sp-merged-route", Namespace: ns}, + suite.ControllerName, + ancestorRef, + ) + + // Test request without credentials - should get 401 from BasicAuth (from Gateway policy) + expectedResponse := httputils.ExpectedResponse{ + Namespace: ns, + Request: httputils.Request{ + Host: "listener1.merged.example.com", + Path: "/merged", + }, + Response: httputils.Response{ + StatusCode: 401, + }, + } + httputils.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + + // Test CORS preflight request - verifies both BasicAuth and CORS are applied + // CORS policy is from route, BasicAuth is from gateway (merged together) + expectedResponse = httputils.ExpectedResponse{ + Namespace: ns, + Request: httputils.Request{ + Host: "listener1.merged.example.com", + Path: "/merged", + Method: http.MethodOptions, + Headers: map[string]string{ + "Origin": "https://www.example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "x-header-1", + }, + }, + Response: httputils.Response{ + StatusCode: 200, + Headers: map[string]string{ + "Access-Control-Allow-Origin": "https://www.example.com", + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Allow-Headers": "x-header-1, x-header-2", + }, + }, + } + httputils.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }) + }, +} diff --git a/test/e2e/tests/utils.go b/test/e2e/tests/utils.go index f0b8dc4fdf..d95efb1232 100644 --- a/test/e2e/tests/utils.go +++ b/test/e2e/tests/utils.go @@ -166,6 +166,29 @@ func SecurityPolicyMustFail( require.NoErrorf(t, waitErr, "error waiting for SecurityPolicy to fail with message: %s policy %v", message, policy) } +// SecurityPolicyMustBeMerged waits for the specified SecurityPolicy to have Merged condition. +func SecurityPolicyMustBeMerged(t *testing.T, client client.Client, policyName types.NamespacedName, controllerName string, ancestorRef gwapiv1.ParentReference) { + t.Helper() + + waitErr := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + policy := &egv1a1.SecurityPolicy{} + err := client.Get(ctx, policyName, policy) + if err != nil { + return false, fmt.Errorf("error fetching SecurityPolicy: %w", err) + } + + if policyMergedByAncestor(policy.Status.Ancestors, controllerName, ancestorRef) { + tlog.Logf(t, "SecurityPolicy has Merged condition: %v", policy) + return true, nil + } + + tlog.Logf(t, "SecurityPolicy does not have Merged condition yet: %v", policy) + return false, nil + }) + + require.NoErrorf(t, waitErr, "error waiting for SecurityPolicy to have Merged condition") +} + // BackendTrafficPolicyMustBeAccepted waits for the specified BackendTrafficPolicy to be accepted. func BackendTrafficPolicyMustBeAccepted(t *testing.T, client client.Client, policyName types.NamespacedName, controllerName string, ancestorRef gwapiv1.ParentReference) { t.Helper() @@ -314,6 +337,19 @@ func policyAcceptedByAncestor(ancestors []gwapiv1.PolicyAncestorStatus, controll return false } +func policyMergedByAncestor(ancestors []gwapiv1.PolicyAncestorStatus, controllerName string, ancestorRef gwapiv1.ParentReference) bool { + for _, ancestor := range ancestors { + if string(ancestor.ControllerName) == controllerName && cmp.Equal(ancestor.AncestorRef, ancestorRef) { + for _, condition := range ancestor.Conditions { + if condition.Type == string(egv1a1.PolicyConditionMerged) && condition.Status == metav1.ConditionTrue { + return true + } + } + } + } + return false +} + // EnvoyExtensionPolicyMustFail waits for an EnvoyExtensionPolicy to fail with the specified reason. func EnvoyExtensionPolicyMustFail( t *testing.T, client client.Client, policyName types.NamespacedName, From 5e5372dde4e12882dde54b385d4f51fd8e95201d Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Tue, 13 Jan 2026 12:10:08 +0530 Subject: [PATCH 05/13] chore: add more test coverage Signed-off-by: Rajat Vig --- internal/gatewayapi/securitypolicy_test.go | 138 ++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/internal/gatewayapi/securitypolicy_test.go b/internal/gatewayapi/securitypolicy_test.go index f4f8bc42b1..725b9e1dc6 100644 --- a/internal/gatewayapi/securitypolicy_test.go +++ b/internal/gatewayapi/securitypolicy_test.go @@ -1468,6 +1468,140 @@ func TestMergeSecurityPolicy(t *testing.T) { }, }, }, + { + name: "merge CORS with Authorization", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://example.com"}, + }, + }, + }, + parentPolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + Authorization: &egv1a1.Authorization{ + DefaultAction: ptr.To(egv1a1.AuthorizationActionDeny), + Rules: []egv1a1.AuthorizationRule{ + {Name: ptr.To("allow-admin"), Action: egv1a1.AuthorizationActionAllow}, + }, + }, + }, + }, + wantSpec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://example.com"}, + }, + Authorization: &egv1a1.Authorization{ + DefaultAction: ptr.To(egv1a1.AuthorizationActionDeny), + Rules: []egv1a1.AuthorizationRule{ + {Name: ptr.To("allow-admin"), Action: egv1a1.AuthorizationActionAllow}, + }, + }, + }, + }, + { + name: "merge with JSONMerge type", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.JSONMerge), + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://route.com"}, + }, + }, + }, + parentPolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + }, + }, + wantSpec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.JSONMerge), + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://route.com"}, + }, + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + }, + }, + { + name: "merge multiple fields - JWT, CORS, and BasicAuth", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://route.com"}, + }, + }, + }, + parentPolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + Authorization: &egv1a1.Authorization{ + DefaultAction: ptr.To(egv1a1.AuthorizationActionAllow), + }, + }, + }, + wantSpec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + JWT: &egv1a1.JWT{ + Providers: []egv1a1.JWTProvider{{Name: "route-jwt"}}, + }, + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://route.com"}, + }, + BasicAuth: &egv1a1.BasicAuth{ + Users: gwapiv1.SecretObjectReference{Name: "gateway-users"}, + }, + Authorization: &egv1a1.Authorization{ + DefaultAction: ptr.To(egv1a1.AuthorizationActionAllow), + }, + }, + }, + { + name: "merge same field - route overrides parent", + routePolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "route-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://route.com"}, + AllowMethods: []string{"GET", "POST"}, + }, + }, + }, + parentPolicy: &egv1a1.SecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-policy", Namespace: "default"}, + Spec: egv1a1.SecurityPolicySpec{ + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://gateway.com"}, + AllowMethods: []string{"GET"}, + }, + }, + }, + wantSpec: egv1a1.SecurityPolicySpec{ + MergeType: ptr.To(egv1a1.StrategicMerge), + CORS: &egv1a1.CORS{ + AllowOrigins: []egv1a1.Origin{"https://route.com"}, + AllowMethods: []string{"GET", "POST"}, + }, + }, + }, } for _, tt := range tests { @@ -1478,10 +1612,12 @@ func TestMergeSecurityPolicy(t *testing.T) { return } if err == nil { - // Compare individual fields + // Compare all fields that could be merged require.Equal(t, tt.wantSpec.MergeType, got.Spec.MergeType, "MergeType should match") require.Equal(t, tt.wantSpec.JWT, got.Spec.JWT, "JWT should match") require.Equal(t, tt.wantSpec.BasicAuth, got.Spec.BasicAuth, "BasicAuth should match") + require.Equal(t, tt.wantSpec.CORS, got.Spec.CORS, "CORS should match") + require.Equal(t, tt.wantSpec.Authorization, got.Spec.Authorization, "Authorization should match") } }) } From 09e0b17011f2c1e25681711f9cc294eddc3ba1d3 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Sun, 18 Jan 2026 22:53:16 +0000 Subject: [PATCH 06/13] chore: fix e2e test Signed-off-by: Rajat Vig --- test/e2e/testdata/securitypolicy-merged.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/e2e/testdata/securitypolicy-merged.yaml b/test/e2e/testdata/securitypolicy-merged.yaml index bebbfa3dd3..8f3a16e73c 100644 --- a/test/e2e/testdata/securitypolicy-merged.yaml +++ b/test/e2e/testdata/securitypolicy-merged.yaml @@ -38,10 +38,8 @@ kind: Secret metadata: name: basic-auth-merged-secret namespace: gateway-conformance-infra -type: kubernetes.io/basic-auth -stringData: - .htpasswd: | - user1:$apr1$F3qTlsKs$AHIXMpXPDKVJw/AXaXAKQ/ +data: + .htpasswd: "dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9" --- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: SecurityPolicy From a190a390e76f9229927701f90782081da71b58d1 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Sun, 18 Jan 2026 23:04:29 +0000 Subject: [PATCH 07/13] chore: add test data files for security policy Signed-off-by: Rajat Vig --- .../securitypolicy-with-merge.in.yaml | 76 +++++ .../securitypolicy-with-merge.out.yaml | 259 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml new file mode 100644 index 0000000000..8a6f4c063c --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml @@ -0,0 +1,76 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: envoy-gateway + name: policy-for-gateway + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http + basicAuth: + users: + name: users-secret +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-route + spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + cors: + allowOrigins: + - "https://www.example.com" + allowMethods: + - GET + - POST + allowHeaders: + - "x-header-1" +secrets: +- apiVersion: v1 + kind: Secret + metadata: + namespace: envoy-gateway + name: users-secret + type: Opaque + data: + .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml new file mode 100644 index 0000000000..14c3cdde64 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml @@ -0,0 +1,259 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-route + namespace: default + spec: + cors: + allowHeaders: + - x-header-1 + allowMethods: + - GET + - POST + allowOrigins: + - https://www.example.com + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: 'BasicAuth: secret default/users-secret does not exist.' + reason: Invalid + status: "False" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-gateway + namespace: envoy-gateway + spec: + basicAuth: + users: + group: null + kind: null + name: users-secret + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being overridden by other securityPolicies for these + routes: [default/httproute-1]' + reason: Overridden + status: "True" + type: Overridden + controllerName: gateway.envoyproxy.io/gatewayclass-controller +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + directResponse: + statusCode: 500 + hostname: '*' + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/* + pathMatch: + distinct: false + name: "" + prefix: /foo + security: + cors: + allowHeaders: + - x-header-1 + allowMethods: + - GET + - POST + allowOrigins: + - distinct: false + exact: https://www.example.com + name: "" + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 From 6cd2177d1bda8fb6a2a1944e82b03db428856033 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Sun, 18 Jan 2026 23:13:59 +0000 Subject: [PATCH 08/13] chore: add docs Signed-off-by: Rajat Vig --- .../gateway_api_extensions/security-policy.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md b/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md index 9470efa15f..177c4b9535 100644 --- a/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md +++ b/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md @@ -203,6 +203,72 @@ spec: In the example, policy A affects only the HTTPS listener, while policy B applies to the rest of the listeners in the gateway. Since Policy A is more specific, the system will show Overridden=True for Policy B on the https listener. +When the `mergeType` field is unset, no merging occurs and only the most specific configuration takes effect. However, policies can be configured to merge with parent policies using the `mergeType` field (see [Policy Merging](#policy-merging) section below). + +## Policy Merging + +SecurityPolicy supports merging configurations using the `mergeType` field, which allows route-level or route rule-level policies to combine with gateway-level or listener-level policies rather than completely overriding them. This enables layered security strategies where platform teams can set baseline security configurations at the Gateway level, while application teams can add specific security policies for their routes. + +When merging occurs, route-level policies will merge with either a gateway-level or listener-level policy, but not both. If both gateway and listener policies exist, the listener-level policy takes precedence. + +### Merge Types + +- **StrategicMerge**: Uses Kubernetes strategic merge patch semantics, providing intelligent merging for complex data structures including arrays +- **JSONMerge**: Uses RFC 7396 JSON Merge Patch semantics, with simple replacement strategy where arrays are completely replaced + +### Example Usage + +Here's an example demonstrating policy merging for combining authentication and CORS policies: + +```yaml +# Platform team: Gateway-level policy with baseline authentication +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: SecurityPolicy +metadata: + name: gateway-security + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: my-gateway + sectionName: https-listener + basicAuth: + users: + name: basic-auth-secret + +--- +# Application team: Route-level policy with CORS configuration +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: SecurityPolicy +metadata: + name: route-security + namespace: default +spec: + mergeType: StrategicMerge # Enables merging with gateway policy + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: my-route + cors: + allowOrigins: + - exact: https://example.com + allowMethods: + - GET + - POST + allowHeaders: + - x-header-1 +``` + +In this example, the route-level policy merges with the gateway-level policy, resulting in both security controls being enforced: the baseline BasicAuth (from Gateway) and the route-specific CORS policy (from Route). This allows platform teams to enforce organization-wide authentication requirements while enabling application teams to configure route-specific cross-origin policies. + +### Key Constraints + +- The `mergeType` field can only be set on policies targeting child resources (like HTTPRoute), not parent resources (like Gateway) +- When `mergeType` is unset, no merging occurs - only the most specific policy takes effect +- The merged configuration combines both policies, enabling layered security strategies +- When the same security feature is configured in both parent and child policies (e.g., both define CORS), the child policy's configuration takes precedence for that specific feature + ## Related Resources - [API Key Authentication](../../tasks/security/apikey-auth.md) - [Basic Authentication](../../tasks/security/basic-auth.md) From d51f1885113310b05a19ee8b713295ed19f4fe25 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Mon, 19 Jan 2026 00:40:42 +0000 Subject: [PATCH 09/13] fix: simplify e2e test Signed-off-by: Rajat Vig --- test/e2e/testdata/securitypolicy-merged.yaml | 20 +++++++-------- test/e2e/tests/securitypolicy_merged.go | 26 +++----------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/test/e2e/testdata/securitypolicy-merged.yaml b/test/e2e/testdata/securitypolicy-merged.yaml index 8f3a16e73c..a1ed0d4ea4 100644 --- a/test/e2e/testdata/securitypolicy-merged.yaml +++ b/test/e2e/testdata/securitypolicy-merged.yaml @@ -33,14 +33,6 @@ spec: - name: infra-backend-v1 port: 8080 --- -apiVersion: v1 -kind: Secret -metadata: - name: basic-auth-merged-secret - namespace: gateway-conformance-infra -data: - .htpasswd: "dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9" ---- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: SecurityPolicy metadata: @@ -52,9 +44,15 @@ spec: kind: Gateway name: sp-merged sectionName: listener-1 - basicAuth: - users: - name: basic-auth-merged-secret + authorization: + defaultAction: Deny + rules: + - name: allow-all + action: Allow + principal: + clientCIDRs: + - 0.0.0.0/0 + - ::/0 --- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: SecurityPolicy diff --git a/test/e2e/tests/securitypolicy_merged.go b/test/e2e/tests/securitypolicy_merged.go index 18a6722229..ad04fb270d 100644 --- a/test/e2e/tests/securitypolicy_merged.go +++ b/test/e2e/tests/securitypolicy_merged.go @@ -8,7 +8,6 @@ package tests import ( - "net/http" "testing" "k8s.io/apimachinery/pkg/types" @@ -69,39 +68,20 @@ var SecurityPolicyMergedTest = suite.ConformanceTest{ ancestorRef, ) - // Test request without credentials - should get 401 from BasicAuth (from Gateway policy) + // Test that merged policies work - Authorization (from gateway) allows, CORS (from route) adds headers expectedResponse := httputils.ExpectedResponse{ Namespace: ns, Request: httputils.Request{ Host: "listener1.merged.example.com", Path: "/merged", - }, - Response: httputils.Response{ - StatusCode: 401, - }, - } - httputils.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) - - // Test CORS preflight request - verifies both BasicAuth and CORS are applied - // CORS policy is from route, BasicAuth is from gateway (merged together) - expectedResponse = httputils.ExpectedResponse{ - Namespace: ns, - Request: httputils.Request{ - Host: "listener1.merged.example.com", - Path: "/merged", - Method: http.MethodOptions, Headers: map[string]string{ - "Origin": "https://www.example.com", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Headers": "x-header-1", + "Origin": "https://www.example.com", }, }, Response: httputils.Response{ StatusCode: 200, Headers: map[string]string{ - "Access-Control-Allow-Origin": "https://www.example.com", - "Access-Control-Allow-Methods": "GET, POST", - "Access-Control-Allow-Headers": "x-header-1, x-header-2", + "Access-Control-Allow-Origin": "https://www.example.com", }, }, } From 96db222869f3fa36f30eec815ba9de75e6b425fe Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Sat, 24 Jan 2026 19:51:04 +0000 Subject: [PATCH 10/13] fix: address feedback and update docs on secrets issues when merging namspaces Signed-off-by: Rajat Vig --- internal/gatewayapi/securitypolicy.go | 18 ++ ...securitypolicy-with-merge-jwt-cors.in.yaml | 74 +++++ ...ecuritypolicy-with-merge-jwt-cors.out.yaml | 277 ++++++++++++++++++ ...uritypolicy-with-merge-tcp-invalid.in.yaml | 110 +++++++ ...ritypolicy-with-merge-tcp-invalid.out.yaml | 229 +++++++++++++++ .../securitypolicy-with-merge.in.yaml | 8 + .../securitypolicy-with-merge.out.yaml | 22 +- .../gateway_api_extensions/security-policy.md | 11 + 8 files changed, 741 insertions(+), 8 deletions(-) create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.in.yaml create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.in.yaml create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index b0515b6ed3..81fcf014c3 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -423,6 +423,18 @@ func (t *Translator) processSecurityPolicyForRoute( continue } + if err := validator(mergedPolicy); err != nil { + status.SetConditionForPolicyAncestor(&policy.Status, + &ancestorRef, + t.GatewayControllerName, + gwapiv1.PolicyConditionAccepted, metav1.ConditionFalse, + egv1a1.PolicyReasonInvalid, + status.Error2ConditionMsg(err), + policy.Generation, + ) + continue + } + // Apply merged policy if err := t.translateSecurityPolicyForRoute(mergedPolicy, targetedRoute, currTarget, resources, xdsIR); err != nil { status.SetConditionForPolicyAncestor(&policy.Status, @@ -469,6 +481,12 @@ func (t *Translator) processSecurityPolicyForRoute( status.SetDeprecatedFieldsWarningForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, policy.Generation, deprecatedFields) } + // Check if this policy is overridden by other policies targeting at route rule levels + // If policy target is route rule, we can skip the check + if currTarget.SectionName != nil { + return + } + // Check if this policy is overridden by other policies targeting at route rule levels key := policyTargetRouteKey{ Kind: string(currTarget.Kind), diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.in.yaml new file mode 100644 index 0000000000..50083bdf3e --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.in.yaml @@ -0,0 +1,74 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/api" + backendRefs: + - name: service-1 + port: 8080 +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: envoy-gateway + name: policy-for-gateway + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http + jwt: + providers: + - name: example + issuer: https://www.example.com + audiences: + - example.com + remoteJWKS: + uri: https://www.example.com/.well-known/jwks.json +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-route + spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + cors: + allowOrigins: + - "https://api.example.com" + allowMethods: + - GET + - POST + - PUT + allowHeaders: + - "authorization" + - "content-type" diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml new file mode 100644 index 0000000000..9e8ba9e8dc --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml @@ -0,0 +1,277 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /api + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-route + namespace: default + spec: + cors: + allowHeaders: + - authorization + - content-type + allowMethods: + - GET + - POST + - PUT + allowOrigins: + - https://api.example.com + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Merged with policy envoy-gateway/policy-for-gateway + reason: Merged + status: "True" + type: Merged + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-gateway + namespace: envoy-gateway + spec: + jwt: + providers: + - audiences: + - example.com + issuer: https://www.example.com + name: example + remoteJWKS: + uri: https://www.example.com/.well-known/jwks.json + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being merged by other securityPolicies for these + routes: [default/httproute-1]' + reason: Merged + status: "True" + type: Merged + controllerName: gateway.envoyproxy.io/gatewayclass-controller +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + hostname: '*' + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/* + pathMatch: + distinct: false + name: "" + prefix: /api + security: + cors: + allowHeaders: + - authorization + - content-type + allowMethods: + - GET + - POST + - PUT + allowOrigins: + - distinct: false + exact: https://api.example.com + name: "" + jwt: + providers: + - audiences: + - example.com + issuer: https://www.example.com + name: example + remoteJWKS: + uri: https://www.example.com/.well-known/jwks.json + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.in.yaml new file mode 100644 index 0000000000..8898dadfe3 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.in.yaml @@ -0,0 +1,110 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: tcp + protocol: TCP + port: 9000 + allowedRoutes: + namespaces: + from: All +tcpRoutes: +- apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: TCPRoute + metadata: + namespace: default + name: tcproute-1 + spec: + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: tcp + rules: + - backendRefs: + - name: tcp-service-1 + port: 8080 +services: +- apiVersion: v1 + kind: Service + metadata: + namespace: default + name: tcp-service-1 + spec: + ports: + - port: 8080 + name: tcp + protocol: TCP +endpointSlices: +- apiVersion: discovery.k8s.io/v1 + kind: EndpointSlice + metadata: + name: endpointslice-tcp-service-1 + namespace: default + labels: + kubernetes.io/service-name: tcp-service-1 + addressType: IPv4 + ports: + - name: tcp + protocol: TCP + port: 8080 + endpoints: + - addresses: + - "10.244.0.5" + conditions: + ready: true +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: envoy-gateway + name: policy-for-gateway + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: tcp + basicAuth: + users: + name: users-secret +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-route + spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: TCPRoute + name: tcproute-1 + authorization: + defaultAction: Deny + rules: + - action: Allow + name: "allow-from-internal" + principal: + clientCIDRs: + - 10.0.0.0/8 +secrets: +- apiVersion: v1 + kind: Secret + metadata: + namespace: envoy-gateway + name: users-secret + type: Opaque + data: + .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 +- apiVersion: v1 + kind: Secret + metadata: + namespace: default + name: users-secret + type: Opaque + data: + .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml new file mode 100644 index 0000000000..96077a4ccd --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml @@ -0,0 +1,229 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: tcp + port: 9000 + protocol: TCP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: tcp + supportedKinds: + - group: gateway.networking.k8s.io + kind: TCPRoute +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/tcp + ports: + - containerPort: 9000 + name: tcp-9000 + protocol: TCP + servicePort: 9000 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-route + namespace: default + spec: + authorization: + defaultAction: Deny + rules: + - action: Allow + name: allow-from-internal + principal: + clientCIDRs: + - 10.0.0.0/8 + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: TCPRoute + name: tcproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: tcp + conditions: + - lastTransitionTime: null + message: Only authorization is supported for TCP (routes/listeners). + reason: Invalid + status: "False" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-gateway + namespace: envoy-gateway + spec: + basicAuth: + users: + group: null + kind: null + name: users-secret + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: tcp + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: tcp + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being overridden by other securityPolicies for these + routes: [default/tcproute-1]' + reason: Overridden + status: "True" + type: Overridden + controllerName: gateway.envoyproxy.io/gatewayclass-controller +tcpRoutes: +- apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: TCPRoute + metadata: + name: tcproute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: tcp + rules: + - backendRefs: + - name: tcp-service-1 + port: 8080 + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: tcp +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 + tcp: + - address: 0.0.0.0 + externalPort: 9000 + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: tcp + name: envoy-gateway/gateway-1/tcp + port: 9000 + routes: + - destination: + metadata: + kind: TCPRoute + name: tcproute-1 + namespace: default + name: tcproute/default/tcproute-1/rule/-1 + settings: + - addressType: IP + endpoints: + - host: 10.244.0.5 + port: 8080 + metadata: + kind: Service + name: tcp-service-1 + namespace: default + sectionName: "8080" + name: tcproute/default/tcproute-1/rule/-1/backend/0 + protocol: TCP + weight: 1 + metadata: + kind: TCPRoute + name: tcproute-1 + namespace: default + name: tcproute/default/tcproute-1 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml index 8a6f4c063c..052513bf81 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge.in.yaml @@ -74,3 +74,11 @@ secrets: type: Opaque data: .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 +- apiVersion: v1 + kind: Secret + metadata: + namespace: default + name: users-secret + type: Opaque + data: + .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml index 14c3cdde64..feed2ad55a 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml @@ -124,9 +124,14 @@ securityPolicies: sectionName: http conditions: - lastTransitionTime: null - message: 'BasicAuth: secret default/users-secret does not exist.' - reason: Invalid - status: "False" + message: Merged with policy envoy-gateway/policy-for-gateway + reason: Merged + status: "True" + type: Merged + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" type: Accepted controllerName: gateway.envoyproxy.io/gatewayclass-controller - apiVersion: gateway.envoyproxy.io/v1alpha1 @@ -160,11 +165,11 @@ securityPolicies: status: "True" type: Accepted - lastTransitionTime: null - message: 'This policy is being overridden by other securityPolicies for these + message: 'This policy is being merged by other securityPolicies for these routes: [default/httproute-1]' - reason: Overridden + reason: Merged status: "True" - type: Overridden + type: Merged controllerName: gateway.envoyproxy.io/gatewayclass-controller xdsIR: envoy-gateway/gateway-1: @@ -228,8 +233,6 @@ xdsIR: name: httproute/default/httproute-1/rule/0/backend/0 protocol: HTTP weight: 1 - directResponse: - statusCode: 500 hostname: '*' isHTTP2: false metadata: @@ -242,6 +245,9 @@ xdsIR: name: "" prefix: /foo security: + basicAuth: + name: securitypolicy/default/policy-for-route + users: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 cors: allowHeaders: - x-header-1 diff --git a/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md b/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md index 177c4b9535..f7bdb45234 100644 --- a/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md +++ b/site/content/en/latest/concepts/gateway_api_extensions/security-policy.md @@ -269,6 +269,17 @@ In this example, the route-level policy merges with the gateway-level policy, re - The merged configuration combines both policies, enabling layered security strategies - When the same security feature is configured in both parent and child policies (e.g., both define CORS), the child policy's configuration takes precedence for that specific feature +### Important: Namespace Behavior with Secret References + +When policies are merged, secret references inherited from parent policies must be resolvable from the **route policy's namespace**. This is because the merged policy retains the identity (including namespace) of the route-level policy. + +**Example scenario:** +- Gateway policy in namespace `envoy-gateway` references `basic-auth-secret` +- Route policy in namespace `default` merges with the gateway policy +- The secret `basic-auth-secret` must exist in the `default` namespace for the merged policy to work + +**Best Practice:** When using policy merging with secret-based authentication (BasicAuth, OIDC, JWT, APIKeyAuth), ensure that required secrets are available in each route's namespace, or design your namespace strategy accordingly. + ## Related Resources - [API Key Authentication](../../tasks/security/apikey-auth.md) - [Basic Authentication](../../tasks/security/basic-auth.md) From 815025f5981ddc4d948ac92dcd201bc01b98a728 Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Sun, 25 Jan 2026 17:57:33 +0000 Subject: [PATCH 11/13] chore: fix generated code Signed-off-by: Rajat Vig --- internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml index feed2ad55a..a6fe947055 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml @@ -247,7 +247,7 @@ xdsIR: security: basicAuth: name: securitypolicy/default/policy-for-route - users: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 + users: '[redacted]' cors: allowHeaders: - x-header-1 From 7d0261d53345e5f4ee08452cfb8f5bc459dd261b Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Sat, 31 Jan 2026 18:10:59 +0000 Subject: [PATCH 12/13] address feedback for multiple IR sends and write a test case to assert fix Signed-off-by: Rajat Vig --- internal/gatewayapi/securitypolicy.go | 15 +- ...typolicy-with-merge-multi-listener.in.yaml | 155 ++++++ ...ypolicy-with-merge-multi-listener.out.yaml | 451 ++++++++++++++++++ 3 files changed, 618 insertions(+), 3 deletions(-) create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.in.yaml create mode 100644 internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index ac91f030ac..8a96dbc58a 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -363,7 +363,7 @@ func (t *Translator) processSecurityPolicyForRoute( // Check if merging is enabled if policy.Spec.MergeType == nil { // No merging - use existing translation logic - if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR); err != nil { + if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR, nil); err != nil { status.SetTranslationErrorForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, @@ -393,7 +393,7 @@ func (t *Translator) processSecurityPolicyForRoute( if gwPolicy == nil && listenerPolicy == nil { // No parent policy found, fall back to current policy - if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR); err != nil { + if err := t.translateSecurityPolicyForRoute(policy, targetedRoute, currTarget, resources, xdsIR, &listener.Name); err != nil { status.SetConditionForPolicyAncestor(&policy.Status, &ancestorRef, t.GatewayControllerName, @@ -438,7 +438,7 @@ func (t *Translator) processSecurityPolicyForRoute( } // Apply merged policy - if err := t.translateSecurityPolicyForRoute(mergedPolicy, targetedRoute, currTarget, resources, xdsIR); err != nil { + if err := t.translateSecurityPolicyForRoute(mergedPolicy, targetedRoute, currTarget, resources, xdsIR, &listener.Name); err != nil { status.SetConditionForPolicyAncestor(&policy.Status, &ancestorRef, t.GatewayControllerName, @@ -818,6 +818,7 @@ func (t *Translator) translateSecurityPolicyForRoute( target gwapiv1.LocalPolicyTargetReferenceWithSectionName, resources *resource.Resources, xdsIR resource.XdsIRMap, + policyTargetListener *gwapiv1.SectionName, ) error { // Build IR var ( @@ -938,6 +939,10 @@ func (t *Translator) translateSecurityPolicyForRoute( switch route.GetRouteType() { case resource.KindTCPRoute: for _, listener := range parentRefCtx.listeners { + // If policyTargetListener is set, only apply to the specific listener + if policyTargetListener != nil && *policyTargetListener != listener.Name { + continue + } tl := xdsIR[irKey].GetTCPListener(irListenerName(listener)) for _, r := range tl.Routes { // If target.SectionName is specified it must match the route-rule section name @@ -957,6 +962,10 @@ func (t *Translator) translateSecurityPolicyForRoute( } case resource.KindHTTPRoute, resource.KindGRPCRoute: for _, listener := range parentRefCtx.listeners { + // If policyTargetListener is set, only apply to the specific listener + if policyTargetListener != nil && *policyTargetListener != listener.Name { + continue + } irListener := xdsIR[irKey].GetHTTPListener(irListenerName(listener)) if irListener != nil { for _, r := range irListener.Routes { diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.in.yaml new file mode 100644 index 0000000000..5dc3bb2a76 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.in.yaml @@ -0,0 +1,155 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http-a + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All + - name: http-b + protocol: HTTP + port: 8080 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + parentRefs: + # Route attaches to BOTH listeners + - namespace: envoy-gateway + name: gateway-1 + sectionName: http-a + - namespace: envoy-gateway + name: gateway-1 + sectionName: http-b + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +securityPolicies: +# Policy for listener-a: BasicAuth +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: envoy-gateway + name: policy-for-listener-a + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http-a + basicAuth: + users: + name: users-secret-a +# Policy for listener-b: ExtAuth (different from listener-a) +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: envoy-gateway + name: policy-for-listener-b + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http-b + extAuth: + http: + backendRefs: + - name: auth-service + namespace: envoy-gateway + port: 9001 +# Route-level policy that merges with parent +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-route + spec: + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + cors: + allowOrigins: + - "https://www.example.com" + allowMethods: + - GET + - POST +services: +- apiVersion: v1 + kind: Service + metadata: + namespace: envoy-gateway + name: auth-service + spec: + ports: + - port: 9001 + name: http + protocol: TCP +referenceGrants: +- apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: ReferenceGrant + metadata: + namespace: envoy-gateway + name: allow-securitypolicy-to-auth-service + spec: + from: + - group: gateway.envoyproxy.io + kind: SecurityPolicy + namespace: default + to: + - group: "" + kind: Service +endpointSlices: +- apiVersion: discovery.k8s.io/v1 + kind: EndpointSlice + metadata: + name: endpointslice-auth-service + namespace: envoy-gateway + labels: + kubernetes.io/service-name: auth-service + addressType: IPv4 + ports: + - name: http + protocol: TCP + port: 9001 + endpoints: + - addresses: + - 10.0.0.1 + conditions: + ready: true +secrets: +- apiVersion: v1 + kind: Secret + metadata: + namespace: envoy-gateway + name: users-secret-a + type: Opaque + data: + .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 +- apiVersion: v1 + kind: Secret + metadata: + namespace: default + name: users-secret-a + type: Opaque + data: + .htpasswd: dXNlcjE6e1NIQX15LzJzWUFqNXlyUUlONFRMMFlkUGRtR05LcGM9 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml new file mode 100644 index 0000000000..7fe8f9bb97 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml @@ -0,0 +1,451 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http-a + port: 80 + protocol: HTTP + - allowedRoutes: + namespaces: + from: All + name: http-b + port: 8080 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http-a + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http-b + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http-a + - name: gateway-1 + namespace: envoy-gateway + sectionName: http-b + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http-a + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http-b +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http-a + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + - address: null + name: envoy-gateway/gateway-1/http-b + ports: + - containerPort: 8080 + name: http-8080 + protocol: HTTP + servicePort: 8080 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-route + namespace: default + spec: + cors: + allowMethods: + - GET + - POST + allowOrigins: + - https://www.example.com + mergeType: StrategicMerge + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-a + conditions: + - lastTransitionTime: null + message: Merged with policy envoy-gateway/policy-for-listener-a + reason: Merged + status: "True" + type: Merged + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-b + conditions: + - lastTransitionTime: null + message: Merged with policy envoy-gateway/policy-for-listener-b + reason: Merged + status: "True" + type: Merged + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-listener-a + namespace: envoy-gateway + spec: + basicAuth: + users: + group: null + kind: null + name: users-secret-a + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http-a + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-a + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being merged by other securityPolicies for these + routes: [default/httproute-1]' + reason: Merged + status: "True" + type: Merged + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-listener-b + namespace: envoy-gateway + spec: + extAuth: + http: + backendRefs: + - name: auth-service + namespace: envoy-gateway + port: 9001 + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + sectionName: http-b + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-b + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: 'This policy is being merged by other securityPolicies for these + routes: [default/httproute-1]' + reason: Merged + status: "True" + type: Merged + controllerName: gateway.envoyproxy.io/gatewayclass-controller +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-a + name: envoy-gateway/gateway-1/http-a + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + hostname: '*' + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/* + pathMatch: + distinct: false + name: "" + prefix: /foo + security: + basicAuth: + name: securitypolicy/default/policy-for-route + users: '[redacted]' + cors: + allowMethods: + - GET + - POST + allowOrigins: + - distinct: false + exact: https://www.example.com + name: "" + - address: 0.0.0.0 + externalPort: 8080 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http-b + name: envoy-gateway/gateway-1/http-b + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 8080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + hostname: '*' + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/* + pathMatch: + distinct: false + name: "" + prefix: /foo + security: + cors: + allowMethods: + - GET + - POST + allowOrigins: + - distinct: false + exact: https://www.example.com + name: "" + extAuth: + http: + authority: auth-service.envoy-gateway:9001 + destination: + metadata: + kind: SecurityPolicy + name: policy-for-route + namespace: default + name: securitypolicy/default/policy-for-route/extauth/0 + settings: + - addressType: IP + endpoints: + - host: 10.0.0.1 + port: 9001 + metadata: + kind: Service + name: auth-service + namespace: envoy-gateway + sectionName: "9001" + name: securitypolicy/default/policy-for-route/extauth/0/backend/0 + protocol: HTTP + weight: 1 + path: "" + name: securitypolicy/default/policy-for-route + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 From 4cf5271c533fdef1275cf919fcb4138488c6f59e Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Wed, 11 Feb 2026 12:11:43 +0000 Subject: [PATCH 13/13] chore: add generted code Signed-off-by: Rajat Vig --- .../testdata/securitypolicy-with-merge-jwt-cors.out.yaml | 3 +-- .../securitypolicy-with-merge-multi-listener.out.yaml | 6 ++---- .../testdata/securitypolicy-with-merge-tcp-invalid.out.yaml | 3 +-- .../gatewayapi/testdata/securitypolicy-with-merge.out.yaml | 3 +-- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml index 9e8ba9e8dc..e6d1bd47c2 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-jwt-cors.out.yaml @@ -78,8 +78,7 @@ infraIR: envoy-gateway/gateway-1: proxy: listeners: - - address: null - name: envoy-gateway/gateway-1/http + - name: envoy-gateway/gateway-1/http ports: - containerPort: 10080 name: http-80 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml index 7fe8f9bb97..4b72cecb33 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-multi-listener.out.yaml @@ -126,15 +126,13 @@ infraIR: envoy-gateway/gateway-1: proxy: listeners: - - address: null - name: envoy-gateway/gateway-1/http-a + - name: envoy-gateway/gateway-1/http-a ports: - containerPort: 10080 name: http-80 protocol: HTTP servicePort: 80 - - address: null - name: envoy-gateway/gateway-1/http-b + - name: envoy-gateway/gateway-1/http-b ports: - containerPort: 8080 name: http-8080 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml index 96077a4ccd..f4b5951fc9 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge-tcp-invalid.out.yaml @@ -40,8 +40,7 @@ infraIR: envoy-gateway/gateway-1: proxy: listeners: - - address: null - name: envoy-gateway/gateway-1/tcp + - name: envoy-gateway/gateway-1/tcp ports: - containerPort: 9000 name: tcp-9000 diff --git a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml index a6fe947055..50596180f7 100644 --- a/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml +++ b/internal/gatewayapi/testdata/securitypolicy-with-merge.out.yaml @@ -78,8 +78,7 @@ infraIR: envoy-gateway/gateway-1: proxy: listeners: - - address: null - name: envoy-gateway/gateway-1/http + - name: envoy-gateway/gateway-1/http ports: - containerPort: 10080 name: http-80