From fd44ae217b7538d929726ad0ff947b72a879814f Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Mon, 24 Nov 2025 16:28:29 +0000 Subject: [PATCH 01/11] Add support for API key authN in TrafficPolicy Signed-off-by: Yossi Mesika --- .github/workflows/e2e.yaml | 2 +- api/v1alpha1/traffic_policy_types.go | 103 +++++ api/v1alpha1/zz_generated.deepcopy.go | 72 +++ .../gateway.kgateway.dev_trafficpolicies.yaml | 127 ++++++ .../plugins/trafficpolicy/api_key_auth.go | 230 ++++++++++ .../trafficpolicy/api_key_auth_test.go | 366 +++++++++++++++ .../plugins/trafficpolicy/constructor.go | 5 + .../plugins/trafficpolicy/merge.go | 16 + .../trafficpolicy/traffic_policy_plugin.go | 16 + internal/kgateway/krtcollections/secrets.go | 6 + .../gateway/gateway_translator_test.go | 33 ++ .../traffic-policy/api-key-auth-gateway.yaml | 78 ++++ .../api-key-auth-httproute.yaml | 82 ++++ .../traffic-policy/api-key-auth-route.yaml | 84 ++++ .../traffic-policy/api-key-auth-gateway.yaml | 169 +++++++ .../api-key-auth-httproute.yaml | 186 ++++++++ .../traffic-policy/api-key-auth-route.yaml | 168 +++++++ test/e2e/features/apikeyauth/suite.go | 424 ++++++++++++++++++ .../testdata/api-key-auth-cookie.yaml | 62 +++ .../testdata/api-key-auth-query.yaml | 62 +++ .../testdata/api-key-auth-secret-update.yaml | 52 +++ .../testdata/api-key-auth-section.yaml | 64 +++ .../apikeyauth/testdata/api-key-auth.yaml | 62 +++ .../features/apikeyauth/testdata/setup.yaml | 14 + test/e2e/features/apikeyauth/types.go | 67 +++ test/e2e/tests/kgateway_tests.go | 2 + 26 files changed, 2551 insertions(+), 1 deletion(-) create mode 100644 internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go create mode 100644 internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml create mode 100644 test/e2e/features/apikeyauth/suite.go create mode 100644 test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml create mode 100644 test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml create mode 100644 test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml create mode 100644 test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml create mode 100644 test/e2e/features/apikeyauth/testdata/api-key-auth.yaml create mode 100644 test/e2e/features/apikeyauth/testdata/setup.yaml create mode 100644 test/e2e/features/apikeyauth/types.go diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 41bf2a7993d..af04da9a0ce 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -54,7 +54,7 @@ jobs: # August 29, 2025: ~10 minutes - cluster-name: 'cluster-five' go-test-args: '-timeout=25m' - go-test-run-regex: '^TestKgateway$$/^TimeoutRetry$$|^TestKgateway$$/^HeaderModifiers$$|^TestKgateway$$/^RBAC$$|^TestKgateway$$/^Deployer$$|^TestKgateway$$/^Transforms$$|^TestRouteReplacement$$|^TestKgateway$$/^RouteDelegation$$' + go-test-run-regex: '^TestKgateway$$/^TimeoutRetry$$|^TestKgateway$$/^HeaderModifiers$$|^TestKgateway$$/^RBAC$$|^TestKgateway$$/^APIKeyAuth$$|^TestKgateway$$/^Deployer$$|^TestKgateway$$/^Transforms$$|^TestRouteReplacement$$|^TestKgateway$$/^RouteDelegation$$' localstack: 'false' # August 29, 2025: ~9 minutes - cluster-name: 'cluster-six' diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index 6acf5df535a..ea0b706b939 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gwv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -121,6 +122,10 @@ type TrafficPolicySpec struct { // This defines the JWT providers and their configurations. // +optional JWT *JWTAuthentication `json:"jwt,omitempty"` + + // APIKeyAuthentication authenticates users based on a configured API Key. + // +optional + APIKeyAuthentication *APIKeyAuthentication `json:"apiKeyAuthentication,omitempty"` } // TransformationPolicy config is used to modify envoy behavior at a route level. @@ -369,6 +374,104 @@ type CSRFPolicy struct { AdditionalOrigins []StringMatcher `json:"additionalOrigins,omitempty"` } +// APIKeySource defines where to extract the API key from within a single key source. +// Within a single key source, if multiple types are specified, precedence is: +// header > query parameter > cookie. The header is checked first, and only falls back +// to query parameter if the header is not present, then to cookie if both header and query +// are not present. +// +kubebuilder:validation:AtLeastOneOf=header;query;cookie +type APIKeySource struct { + // header specifies the name of the header that contains the API key. + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Header *string `json:"header,omitempty"` + + // query specifies the name of the query parameter that contains the API key. + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Query *string `json:"query,omitempty"` + + // cookie specifies the name of the cookie that contains the API key. + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Cookie *string `json:"cookie,omitempty"` +} + +// +kubebuilder:validation:ExactlyOneOf=secretRef;secretSelector +type APIKeyAuthentication struct { + // keySources specifies the list of key sources to extract the API key from. + // Key sources are processed in array order and the first one that successfully + // extracts a key is used. Within each key source, if multiple types (header, query, cookie) are + // specified, precedence is: header > query parameter > cookie. + // + // If empty, defaults to a single key source with header "api-key". + // + // Example: + // keySources: + // - header: "X-API-KEY" + // - query: "api_key" + // - header: "Authorization" + // query: "token" + // cookie: "auth_token" + // + // In this example, the system will: + // 1. First try header "X-API-KEY" + // 2. If not found, try query parameter "api_key" + // 3. If not found, try header "Authorization" (then query "token", then cookie "auth_token" within that key source) + // + // +kubebuilder:validation:MinItems=0 + // +kubebuilder:validation:MaxItems=16 + // +optional + KeySources []APIKeySource `json:"keySources,omitempty"` + + // hideAPIKey removes the API key from the request before forwarding upstream. + // This applies to all configured key sources (header, query parameter, or cookie). + // +kubebuilder:default=false + // +optional + HideAPIKey *bool `json:"hideAPIKey,omitempty"` + + // secretRef references a Kubernetes secret storing a set of API Keys. If there are many keys, 'secretSelector' can be + // used instead. + // + // Each entry in the Secret represents one API Key. The key is an arbitrary identifier. + // The value is a string, representing the API Key. + // + // Example: + // + // apiVersion: v1 + // kind: Secret + // metadata: + // name: api-key + // stringData: + // client1: "k-123", + // client2: "k-456" + // + // +optional + SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` + + // secretSelector selects multiple secrets containing API Keys. If the same key is defined in multiple secrets, the + // behavior is undefined. + // + // Each entry in the Secret represents one API Key. The key is an arbitrary identifier. + // The value is a string, representing the API Key. + // + // Example: + // + // apiVersion: v1 + // kind: Secret + // metadata: + // name: api-key + // stringData: + // client1: "k-123", + // client2: "k-456" + // + // +optional + SecretSelector *SecretSelector `json:"secretSelector,omitempty"` +} + // HeaderModifiers can be used to define the policy to modify request and response headers. // +kubebuilder:validation:AtLeastOneOf=request;response type HeaderModifiers struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index fc107ddc8b8..8a150318735 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -93,6 +93,73 @@ func (in *AIPromptGuard) DeepCopy() *AIPromptGuard { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIKeyAuthentication) DeepCopyInto(out *APIKeyAuthentication) { + *out = *in + if in.KeySources != nil { + in, out := &in.KeySources, &out.KeySources + *out = make([]APIKeySource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.HideAPIKey != nil { + in, out := &in.HideAPIKey, &out.HideAPIKey + *out = new(bool) + **out = **in + } + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.SecretSelector != nil { + in, out := &in.SecretSelector, &out.SecretSelector + *out = new(SecretSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKeyAuthentication. +func (in *APIKeyAuthentication) DeepCopy() *APIKeyAuthentication { + if in == nil { + return nil + } + out := new(APIKeyAuthentication) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIKeySource) DeepCopyInto(out *APIKeySource) { + *out = *in + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = new(string) + **out = **in + } + if in.Query != nil { + in, out := &in.Query, &out.Query + *out = new(string) + **out = **in + } + if in.Cookie != nil { + in, out := &in.Cookie, &out.Cookie + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIKeySource. +func (in *APIKeySource) DeepCopy() *APIKeySource { + if in == nil { + return nil + } + out := new(APIKeySource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSGuardrailConfig) DeepCopyInto(out *AWSGuardrailConfig) { *out = *in @@ -6349,6 +6416,11 @@ func (in *TrafficPolicySpec) DeepCopyInto(out *TrafficPolicySpec) { *out = new(JWTAuthentication) (*in).DeepCopyInto(*out) } + if in.APIKeyAuthentication != nil { + in, out := &in.APIKeyAuthentication, &out.APIKeyAuthentication + *out = new(APIKeyAuthentication) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrafficPolicySpec. diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index fc59673a0da..8bfd7da3ea0 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -54,6 +54,133 @@ spec: description: TrafficPolicySpec defines the desired state of a traffic policy. properties: + apiKeyAuthentication: + description: APIKeyAuthentication authenticates users based on a configured + API Key. + properties: + hideAPIKey: + default: false + description: |- + hideAPIKey removes the API key from the request before forwarding upstream. + This applies to all configured key sources (header, query parameter, or cookie). + type: boolean + keySources: + description: |- + keySources specifies the list of key sources to extract the API key from. + Key sources are processed in array order and the first one that successfully + extracts a key is used. Within each key source, if multiple types (header, query, cookie) are + specified, precedence is: header > query parameter > cookie. + + If empty, defaults to a single key source with header "api-key". + + Example: + keySources: + - header: "X-API-KEY" + - query: "api_key" + - header: "Authorization" + query: "token" + cookie: "auth_token" + + In this example, the system will: + 1. First try header "X-API-KEY" + 2. If not found, try query parameter "api_key" + 3. If not found, try header "Authorization" (then query "token", then cookie "auth_token" within that key source) + items: + description: |- + APIKeySource defines where to extract the API key from within a single key source. + Within a single key source, if multiple types are specified, precedence is: + header > query parameter > cookie. The header is checked first, and only falls back + to query parameter if the header is not present, then to cookie if both header and query + are not present. + properties: + cookie: + description: cookie specifies the name of the cookie that + contains the API key. + maxLength: 256 + minLength: 1 + type: string + header: + description: header specifies the name of the header that + contains the API key. + maxLength: 256 + minLength: 1 + type: string + query: + description: query specifies the name of the query parameter + that contains the API key. + maxLength: 256 + minLength: 1 + type: string + type: object + x-kubernetes-validations: + - message: at least one of the fields in [header query cookie] + must be set + rule: '[has(self.header),has(self.query),has(self.cookie)].filter(x,x==true).size() + >= 1' + maxItems: 16 + minItems: 0 + type: array + secretRef: + description: |- + secretRef references a Kubernetes secret storing a set of API Keys. If there are many keys, 'secretSelector' can be + used instead. + + Each entry in the Secret represents one API Key. The key is an arbitrary identifier. + The value is a string, representing the API Key. + + Example: + + apiVersion: v1 + kind: Secret + metadata: + name: api-key + stringData: + client1: "k-123", + client2: "k-456" + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + secretSelector: + description: |- + secretSelector selects multiple secrets containing API Keys. If the same key is defined in multiple secrets, the + behavior is undefined. + + Each entry in the Secret represents one API Key. The key is an arbitrary identifier. + The value is a string, representing the API Key. + + Example: + + apiVersion: v1 + kind: Secret + metadata: + name: api-key + stringData: + client1: "k-123", + client2: "k-456" + properties: + matchLabels: + additionalProperties: + type: string + description: Label selector to select the target resource. + type: object + required: + - matchLabels + type: object + type: object + x-kubernetes-validations: + - message: exactly one of the fields in [secretRef secretSelector] + must be set + rule: '[has(self.secretRef),has(self.secretSelector)].filter(x,x==true).size() + == 1' autoHostRewrite: description: |- AutoHostRewrite rewrites the Host header to the DNS name of the selected upstream. diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go new file mode 100644 index 00000000000..0d4a0aaacf5 --- /dev/null +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -0,0 +1,230 @@ +package trafficpolicy + +import ( + "fmt" + + envoyapikeyauthv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/api_key_auth/v3" + "google.golang.org/protobuf/proto" + "istio.io/istio/pkg/kube/krt" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kgateway-dev/kgateway/v2/api/v1alpha1" + "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk/collections" + "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk/ir" +) + +const ( + apiKeyAuthFilterNamePrefix = "envoy.filters.http.api_key_auth" //nolint:gosec +) + +// apiKeyAuthIR is the internal representation of an API key authentication policy. +type apiKeyAuthIR struct { + config *envoyapikeyauthv3.ApiKeyAuth +} + +func (a *apiKeyAuthIR) Equals(other *apiKeyAuthIR) bool { + if a == nil && other == nil { + return true + } + if a == nil || other == nil { + return false + } + if a.config == nil && other.config == nil { + return true + } + if a.config == nil || other.config == nil { + return false + } + // Compare the serialized configs for equality using proto.Equal + return proto.Equal(a.config, other.config) +} + +// Validate performs validation on the API key auth component. +func (a *apiKeyAuthIR) Validate() error { + if a == nil { + return nil + } + if a.config == nil { + return nil + } + return a.config.Validate() +} + +// constructAPIKeyAuth translates the API key authentication spec into an Envoy API key auth filter configuration +func constructAPIKeyAuth( + krtctx krt.HandlerContext, + policy *v1alpha1.TrafficPolicy, + commoncol *collections.CommonCollections, + out *trafficPolicySpecIr, +) error { + spec := policy.Spec + if spec.APIKeyAuthentication == nil { + return nil + } + + ak := spec.APIKeyAuthentication + + // Resolve secrets using SecretIndex + var secrets []*ir.Secret + secretGK := schema.GroupKind{Group: "", Kind: "Secret"} + secretCol := commoncol.Secrets.GetSecretCollection(secretGK) + if secretCol == nil { + return fmt.Errorf("secret collection not found") + } + + if ak.SecretRef != nil { + // Use ResourceName format: Group/Kind/Namespace/Name + secretObjSource := ir.ObjectSource{ + Group: "", + Kind: "Secret", + Namespace: policy.Namespace, + Name: ak.SecretRef.Name, + } + secret := krt.FetchOne(krtctx, secretCol, krt.FilterKey(secretObjSource.ResourceName())) + if secret == nil { + return fmt.Errorf("API key secret %s not found in namespace %s", ak.SecretRef.Name, policy.Namespace) + } + secrets = []*ir.Secret{secret} + } else if ak.SecretSelector != nil { + // Fetch secrets matching labels, then filter by namespace + allSecrets := krt.Fetch(krtctx, secretCol, krt.FilterLabel(ak.SecretSelector.MatchLabels)) + for i := range allSecrets { + secret := &allSecrets[i] + if secret.Namespace == policy.Namespace { + secrets = append(secrets, secret) + } + } + if len(secrets) == 0 { + return fmt.Errorf("no secrets found matching selector %v in namespace %s", ak.SecretSelector.MatchLabels, policy.Namespace) + } + } else { + return fmt.Errorf("either secretRef or secretSelector must be specified") + } + + // Parse secrets and build credentials + var credentials []*envoyapikeyauthv3.Credential + var errs []error + + for _, secret := range secrets { + for keyName, keyValue := range secret.Data { + // Skip empty values + if len(keyValue) == 0 { + continue + } + + // The value is expected to be a plain string representing the API key + // The secret key name becomes the client identifier + apiKey := string(keyValue) + if apiKey == "" { + errs = append(errs, fmt.Errorf("secret %s key %s has empty API key value", secret.ObjectSource.Name, keyName)) + continue + } + + credentials = append(credentials, &envoyapikeyauthv3.Credential{ + Key: apiKey, + Client: keyName, + }) + } + } + + if len(errs) > 0 { + return fmt.Errorf("errors processing API key secrets: %v", errs) + } + + if len(credentials) == 0 { + return fmt.Errorf("no valid API keys found in secrets") + } + + // Convert API KeySources to Envoy KeySource format + var envoyKeySources []*envoyapikeyauthv3.KeySource + if len(ak.KeySources) > 0 { + for _, keySource := range ak.KeySources { + envoyKeySource := &envoyapikeyauthv3.KeySource{} + if keySource.Header != nil && *keySource.Header != "" { + envoyKeySource.Header = *keySource.Header + } + if keySource.Query != nil && *keySource.Query != "" { + envoyKeySource.Query = *keySource.Query + } + if keySource.Cookie != nil && *keySource.Cookie != "" { + envoyKeySource.Cookie = *keySource.Cookie + } + // Only add if at least one source is specified + if envoyKeySource.Header != "" || envoyKeySource.Query != "" || envoyKeySource.Cookie != "" { + envoyKeySources = append(envoyKeySources, envoyKeySource) + } + } + } + + // If no key sources were specified, default to "api-key" header + if len(envoyKeySources) == 0 { + envoyKeySources = []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + } + } + + // Determine hide credentials (default to false) + hideCredentials := false + if ak.HideAPIKey != nil { + hideCredentials = *ak.HideAPIKey + } + + // Build Envoy API key auth filter configuration + apiKeyAuthConfig := &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: credentials, + KeySources: envoyKeySources, + Forwarding: &envoyapikeyauthv3.Forwarding{ + Header: "x-client-id", + HideCredentials: hideCredentials, + }, + } + + out.apiKeyAuth = &apiKeyAuthIR{ + config: apiKeyAuthConfig, + } + + return nil +} + +// handleAPIKeyAuth configures the API key auth filter and per-route API key auth configuration. +// This follows the same pattern as RBAC: add an empty filter to the chain and put the actual config +// in the typedPerFilterConfig. The per-route config will be applied at RouteConfiguration level for +// gateway-level policies, and at Route level for route-level policies (which will override the +// RouteConfiguration level config). +// +// IMPORTANT: For route-level-only policies (no gateway-level policy), we add FilterConfig with +// disabled: true at the RouteConfiguration level in ApplyRouteConfigPlugin. This disables the filter +// for all routes by default. Routes with policies will override this with ApiKeyAuthPerRoute, which +// enables the filter for those specific routes. +func (p *trafficPolicyPluginGwPass) handleAPIKeyAuth( + fcn string, + pCtxTypedFilterConfig *ir.TypedFilterConfigMap, + apiKeyAuthIr *apiKeyAuthIR, +) { + if apiKeyAuthIr == nil || apiKeyAuthIr.config == nil { + return + } + + // Always add the filter to the chain if not already present. + // For route-level-only policies, it will be disabled at RouteConfiguration level, + // and enabled per-route via ApiKeyAuthPerRoute for routes with policies. + if p.apiKeyAuthInChain == nil { + p.apiKeyAuthInChain = make(map[string]*envoyapikeyauthv3.ApiKeyAuth) + } + if _, ok := p.apiKeyAuthInChain[fcn]; !ok { + p.apiKeyAuthInChain[fcn] = &envoyapikeyauthv3.ApiKeyAuth{} + } + + // Always add the per-route API key auth configuration to the typed filter config + // This will be applied at RouteConfiguration level for gateway-level policies, + // and at Route level for route-level policies (overriding RouteConfiguration level) + perRouteConfig := &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + Credentials: apiKeyAuthIr.config.Credentials, + KeySources: apiKeyAuthIr.config.KeySources, + Forwarding: apiKeyAuthIr.config.Forwarding, + } + + pCtxTypedFilterConfig.AddTypedConfig(apiKeyAuthFilterNamePrefix, perRouteConfig) +} diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go new file mode 100644 index 00000000000..e5214379a43 --- /dev/null +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go @@ -0,0 +1,366 @@ +package trafficpolicy + +import ( + "testing" + + envoyapikeyauthv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/api_key_auth/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk/ir" +) + +func TestAPIKeyAuthIREquals(t *testing.T) { + // Helper to create simple API key auth configurations for testing + createAPIKeyAuth := func(headerName string, hideCredentials bool) *envoyapikeyauthv3.ApiKeyAuth { + return &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + { + Key: "test-key", + Client: "test-client", + }, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: headerName, + }, + }, + Forwarding: &envoyapikeyauthv3.Forwarding{ + Header: "x-client-id", + HideCredentials: hideCredentials, + }, + } + } + + tests := []struct { + name string + apiKeyAuth1 *apiKeyAuthIR + apiKeyAuth2 *apiKeyAuthIR + expected bool + }{ + { + name: "both nil are equal", + apiKeyAuth1: nil, + apiKeyAuth2: nil, + expected: true, + }, + { + name: "nil vs non-nil are not equal", + apiKeyAuth1: nil, + apiKeyAuth2: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)}, + expected: false, + }, + { + name: "non-nil vs nil are not equal", + apiKeyAuth1: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)}, + apiKeyAuth2: nil, + expected: false, + }, + { + name: "both config nil are equal", + apiKeyAuth1: &apiKeyAuthIR{config: nil}, + apiKeyAuth2: &apiKeyAuthIR{config: nil}, + expected: true, + }, + { + name: "same configuration is equal", + apiKeyAuth1: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)}, + apiKeyAuth2: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)}, + expected: true, + }, + { + name: "different header names are not equal", + apiKeyAuth1: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)}, + apiKeyAuth2: &apiKeyAuthIR{config: createAPIKeyAuth("x-api-key", false)}, + expected: false, + }, + { + name: "different hide credentials settings are not equal", + apiKeyAuth1: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)}, + apiKeyAuth2: &apiKeyAuthIR{config: createAPIKeyAuth("api-key", true)}, + expected: false, + }, + { + name: "different credentials are not equal", + apiKeyAuth1: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key1", Client: "client1"}, + }, + }, + }, + apiKeyAuth2: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key2", Client: "client2"}, + }, + }, + }, + expected: false, + }, + { + name: "same credentials are equal", + apiKeyAuth1: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key1", Client: "client1"}, + }, + }, + }, + apiKeyAuth2: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key1", Client: "client1"}, + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.apiKeyAuth1.Equals(tt.apiKeyAuth2) + assert.Equal(t, tt.expected, result) + + // Test symmetry: a.Equals(b) should equal b.Equals(a) + reverseResult := tt.apiKeyAuth2.Equals(tt.apiKeyAuth1) + assert.Equal(t, result, reverseResult, "Equals should be symmetric") + }) + } + + // Test reflexivity: x.Equals(x) should always be true for non-nil values + t.Run("reflexivity", func(t *testing.T) { + apiKeyAuth := &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)} + assert.True(t, apiKeyAuth.Equals(apiKeyAuth), "apiKeyAuth should equal itself") + }) + + // Test transitivity: if a.Equals(b) && b.Equals(c), then a.Equals(c) + t.Run("transitivity", func(t *testing.T) { + a := &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)} + b := &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)} + c := &apiKeyAuthIR{config: createAPIKeyAuth("api-key", false)} + + assert.True(t, a.Equals(b), "a should equal b") + assert.True(t, b.Equals(c), "b should equal c") + assert.True(t, a.Equals(c), "a should equal c (transitivity)") + }) +} + +func TestAPIKeyAuthIRValidate(t *testing.T) { + tests := []struct { + name string + apiKeyAuth *apiKeyAuthIR + wantErr bool + }{ + { + name: "nil IR validates successfully", + apiKeyAuth: nil, + wantErr: false, + }, + { + name: "nil config validates successfully", + apiKeyAuth: &apiKeyAuthIR{config: nil}, + wantErr: false, + }, + { + name: "valid config validates successfully", + apiKeyAuth: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + { + Key: "test-key", + Client: "test-client", + }, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + }, + Forwarding: &envoyapikeyauthv3.Forwarding{ + Header: "x-client-id", + HideCredentials: false, + }, + }, + }, + wantErr: false, + }, + { + name: "config with empty credentials validates successfully", + apiKeyAuth: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{}, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.apiKeyAuth.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestHandleAPIKeyAuth(t *testing.T) { + tests := []struct { + name string + apiKeyAuthIr *apiKeyAuthIR + expectChain bool + expectRoute bool + }{ + { + name: "nil IR does nothing", + apiKeyAuthIr: nil, + expectChain: false, + expectRoute: false, + }, + { + name: "nil config does nothing", + apiKeyAuthIr: &apiKeyAuthIR{config: nil}, + expectChain: false, + expectRoute: false, + }, + { + name: "valid config adds to chain and route", + apiKeyAuthIr: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + { + Key: "test-key", + Client: "test-client", + }, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + }, + Forwarding: &envoyapikeyauthv3.Forwarding{ + Header: "x-client-id", + HideCredentials: false, + }, + }, + }, + expectChain: true, + expectRoute: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &trafficPolicyPluginGwPass{ + apiKeyAuthInChain: make(map[string]*envoyapikeyauthv3.ApiKeyAuth), + } + fcn := "test-filter-chain" + typedFilterConfig := &ir.TypedFilterConfigMap{} + + plugin.handleAPIKeyAuth(fcn, typedFilterConfig, tt.apiKeyAuthIr) + + if tt.expectChain { + assert.NotNil(t, plugin.apiKeyAuthInChain[fcn], "should add to chain") + } else { + assert.Nil(t, plugin.apiKeyAuthInChain[fcn], "should not add to chain") + } + + if tt.expectRoute { + config := typedFilterConfig.GetTypedConfig(apiKeyAuthFilterNamePrefix) + assert.NotNil(t, config, "should add per-route config") + if config != nil { + perRouteConfig, ok := config.(*envoyapikeyauthv3.ApiKeyAuthPerRoute) + require.True(t, ok, "config should be ApiKeyAuthPerRoute") + assert.NotNil(t, perRouteConfig.Credentials) + assert.NotNil(t, perRouteConfig.KeySources) + } + } else { + config := typedFilterConfig.GetTypedConfig(apiKeyAuthFilterNamePrefix) + assert.Nil(t, config, "should not add per-route config") + } + }) + } +} + +func TestHandleAPIKeyAuth_MultipleCalls(t *testing.T) { + plugin := &trafficPolicyPluginGwPass{ + apiKeyAuthInChain: make(map[string]*envoyapikeyauthv3.ApiKeyAuth), + } + fcn := "test-filter-chain" + typedFilterConfig := &ir.TypedFilterConfigMap{} + + config := &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + { + Key: "test-key", + Client: "test-client", + }, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + }, + } + + apiKeyAuthIr := &apiKeyAuthIR{config: config} + + // First call + plugin.handleAPIKeyAuth(fcn, typedFilterConfig, apiKeyAuthIr) + assert.NotNil(t, plugin.apiKeyAuthInChain[fcn], "first call should add to chain") + + // Second call with same config should not overwrite + plugin.handleAPIKeyAuth(fcn, typedFilterConfig, apiKeyAuthIr) + assert.NotNil(t, plugin.apiKeyAuthInChain[fcn], "second call should not remove from chain") + + // Verify the config is the same (not overwritten) + assert.True(t, proto.Equal(config, plugin.apiKeyAuthInChain[fcn]), "config should not be overwritten") +} + +func TestHandleAPIKeyAuth_DifferentFilterChains(t *testing.T) { + plugin := &trafficPolicyPluginGwPass{ + apiKeyAuthInChain: make(map[string]*envoyapikeyauthv3.ApiKeyAuth), + } + + config1 := &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key1", Client: "client1"}, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + {Header: "api-key"}, + }, + } + + config2 := &envoyapikeyauthv3.ApiKeyAuth{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key2", Client: "client2"}, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + {Header: "x-api-key"}, + }, + } + + fcn1 := "filter-chain-1" + fcn2 := "filter-chain-2" + typedFilterConfig1 := &ir.TypedFilterConfigMap{} + typedFilterConfig2 := &ir.TypedFilterConfigMap{} + + plugin.handleAPIKeyAuth(fcn1, typedFilterConfig1, &apiKeyAuthIR{config: config1}) + plugin.handleAPIKeyAuth(fcn2, typedFilterConfig2, &apiKeyAuthIR{config: config2}) + + assert.NotNil(t, plugin.apiKeyAuthInChain[fcn1], "should add config for chain 1") + assert.NotNil(t, plugin.apiKeyAuthInChain[fcn2], "should add config for chain 2") + assert.True(t, proto.Equal(config1, plugin.apiKeyAuthInChain[fcn1]), "chain 1 should have correct config") + assert.True(t, proto.Equal(config2, plugin.apiKeyAuthInChain[fcn2]), "chain 2 should have correct config") +} diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/constructor.go b/internal/kgateway/extensions2/plugins/trafficpolicy/constructor.go index 7242f7012f6..51bab2fdf80 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/constructor.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/constructor.go @@ -96,6 +96,11 @@ func (c *TrafficPolicyConstructor) ConstructIR( errors = append(errors, err) } + // Construct API key auth specific IR + if err := constructAPIKeyAuth(krtctx, policyCR, c.commoncol, &outSpec); err != nil { + errors = append(errors, err) + } + // Construct jwt specific IR if err := constructJwt(krtctx, policyCR, &outSpec, c.FetchGatewayExtension); err != nil { errors = append(errors, err) diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/merge.go b/internal/kgateway/extensions2/plugins/trafficpolicy/merge.go index d0a5566b5e9..3e58560d337 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/merge.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/merge.go @@ -52,6 +52,7 @@ func MergeTrafficPolicies( mergeRetry, mergeRBAC, mergeJwt, + mergeAPIKeyAuth, } for _, mergeFunc := range mergeFuncs { @@ -438,6 +439,21 @@ func mergeRBAC( defaultMerge(p1, p2, p2Ref, p2MergeOrigins, opts, mergeOrigins, accessor, "rbac") } +func mergeAPIKeyAuth( + p1, p2 *TrafficPolicy, + p2Ref *ir.AttachedPolicyRef, + p2MergeOrigins ir.MergeOrigins, + opts policy.MergeOptions, + mergeOrigins ir.MergeOrigins, + _ TrafficPolicyMergeOpts, +) { + accessor := fieldAccessor[apiKeyAuthIR]{ + Get: func(spec *trafficPolicySpecIr) *apiKeyAuthIR { return spec.apiKeyAuth }, + Set: func(spec *trafficPolicySpecIr, val *apiKeyAuthIR) { spec.apiKeyAuth = val }, + } + defaultMerge(p1, p2, p2Ref, p2MergeOrigins, opts, mergeOrigins, accessor, "apiKeyAuth") +} + func mergeRetry( p1, p2 *TrafficPolicy, p2Ref *ir.AttachedPolicyRef, diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go b/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go index 9850e619b12..dbd4625c93b 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go @@ -6,6 +6,7 @@ import ( envoyroutev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" exteniondynamicmodulev3 "github.com/envoyproxy/go-control-plane/envoy/extensions/dynamic_modules/v3" + envoyapikeyauthv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/api_key_auth/v3" bufferv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/buffer/v3" corsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cors/v3" envoy_csrf_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/csrf/v3" @@ -14,6 +15,7 @@ import ( localratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/local_ratelimit/v3" envoyrbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" envoy_wellknown "github.com/envoyproxy/go-control-plane/pkg/wellknown" + // TODO(nfuden): remove once rustformations are able to be used in a production environment transformationpb "github.com/solo-io/envoy-gloo/go/config/filter/http/transformation/v2" "google.golang.org/protobuf/types/known/anypb" @@ -88,6 +90,7 @@ type trafficPolicySpecIr struct { timeouts *timeoutsIR rbac *rbacIR jwt *jwtIr + apiKeyAuth *apiKeyAuthIR } func (d *TrafficPolicy) CreationTime() time.Time { @@ -148,6 +151,9 @@ func (d *TrafficPolicy) Equals(in any) bool { if !d.spec.jwt.Equals(d2.spec.jwt) { return false } + if !d.spec.apiKeyAuth.Equals(d2.spec.apiKeyAuth) { + return false + } return true } @@ -169,6 +175,7 @@ func (p *TrafficPolicy) Validate() error { validators = append(validators, p.spec.autoHostRewrite.Validate) validators = append(validators, p.spec.rbac.Validate) validators = append(validators, p.spec.jwt.Validate) + validators = append(validators, p.spec.apiKeyAuth.Validate) for _, validator := range validators { if err := validator(); err != nil { return err @@ -193,6 +200,7 @@ type trafficPolicyPluginGwPass struct { csrfInChain map[string]*envoy_csrf_v3.CsrfPolicy headerMutationInChain map[string]*header_mutationv3.HeaderMutationPerRoute bufferInChain map[string]*bufferv3.Buffer + apiKeyAuthInChain map[string]*envoyapikeyauthv3.ApiKeyAuth } var _ ir.ProxyTranslationPass = &trafficPolicyPluginGwPass{} @@ -497,6 +505,13 @@ func (p *trafficPolicyPluginGwPass) HttpFilters(fcc ir.FilterChainCommon) ([]fil stagedFilters = append(stagedFilters, filter) } + // Add API key auth filter to the chain + if f := p.apiKeyAuthInChain[fcc.FilterChainName]; f != nil { + filter := filters.MustNewStagedFilter(apiKeyAuthFilterNamePrefix, f, filters.DuringStage(filters.AuthNStage)) + filter.Filter.Disabled = true + stagedFilters = append(stagedFilters, filter) + } + if len(stagedFilters) == 0 { return nil, nil } @@ -530,6 +545,7 @@ func (p *trafficPolicyPluginGwPass) handlePolicies( p.handleHeaderModifiers(fcn, typedFilterConfig, spec.headerModifiers) p.handleBuffer(fcn, typedFilterConfig, spec.buffer) p.handleRBAC(fcn, typedFilterConfig, spec.rbac) + p.handleAPIKeyAuth(fcn, typedFilterConfig, spec.apiKeyAuth) } // handlePerRoutePolicies handles policies that are meant to be processed at the route level diff --git a/internal/kgateway/krtcollections/secrets.go b/internal/kgateway/krtcollections/secrets.go index 35669600c7d..a03ee780ea3 100644 --- a/internal/kgateway/krtcollections/secrets.go +++ b/internal/kgateway/krtcollections/secrets.go @@ -68,3 +68,9 @@ func (s *SecretIndex) GetSecret(kctx krt.HandlerContext, from From, secretRef gw } return secret, nil } + +// GetSecretCollection returns the secret collection for a given GroupKind. +// This is useful for accessing secrets without reference grant checks (e.g., same namespace). +func (s *SecretIndex) GetSecretCollection(gk schema.GroupKind) krt.Collection[ir.Secret] { + return s.secrets[gk] +} diff --git a/internal/kgateway/translator/gateway/gateway_translator_test.go b/internal/kgateway/translator/gateway/gateway_translator_test.go index b1130f23e7f..d779a2244d0 100644 --- a/internal/kgateway/translator/gateway/gateway_translator_test.go +++ b/internal/kgateway/translator/gateway/gateway_translator_test.go @@ -285,6 +285,39 @@ func TestBasic(t *testing.T) { }) }) + t.Run("TrafficPolicy API Key Authentication at route level", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-route.yaml", + outputFile: "traffic-policy/api-key-auth-route.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + + t.Run("TrafficPolicy API Key Authentication at httproute level", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-httproute.yaml", + outputFile: "traffic-policy/api-key-auth-httproute.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + + t.Run("TrafficPolicy API Key Authentication at gateway level", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-gateway.yaml", + outputFile: "traffic-policy/api-key-auth-gateway.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + t.Run("TrafficPolicy with fail open rate limiting", func(t *testing.T) { test(t, translatorTestCase{ inputFile: "traffic-policy/fail-open", diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml new file mode 100644 index 00000000000..f461c94cb6a --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml @@ -0,0 +1,78 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: / +--- +# API key secret with multiple keys +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: default +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# TrafficPolicy with API key authentication at gateway level +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-gateway + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + apiKeyAuthentication: + keySources: + - header: "api-key" + query: "api-key-query" + cookie: "api-key-cookie" + - header: "api-key-other" + hideAPIKey: false + secretRef: + name: api-keys +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml new file mode 100644 index 00000000000..ab00bd9464a --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml @@ -0,0 +1,82 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /foo + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /bar +--- +# API key secret with multiple keys +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: default +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# TrafficPolicy with API key authentication at HTTPRoute level +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-httproute + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: example-route + apiKeyAuthentication: + keySources: + - header: "x-api-key" + hideAPIKey: true + secretRef: + name: api-keys +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml new file mode 100644 index 00000000000..4cf6845c529 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml @@ -0,0 +1,84 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /foo + filters: + - type: ExtensionRef + extensionRef: + group: gateway.kgateway.dev + kind: TrafficPolicy + name: api-key-auth-route + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /bar +--- +# API key secret with multiple keys +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: default +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# TrafficPolicy with API key authentication at route level (via ExtensionRef) +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-route + namespace: default +spec: + apiKeyAuthentication: + keySources: + - header: "x-api-key" + hideAPIKey: true + secretRef: + name: api-keys +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml new file mode 100644 index 00000000000..1d086a5a22f --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml @@ -0,0 +1,169 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-gateway + name: listener~80 +Routes: +- ignorePortInHostMatching: true + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-gateway + name: listener~80 + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client2 + key: k-456 + - client: client1 + key: k-123 + forwarding: + header: x-client-id + keySources: + - cookie: api-key-cookie + header: api-key + query: api-key-query + - header: api-key-other + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + prefix: / + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-gateway: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml new file mode 100644 index 00000000000..87c8d7741e9 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml @@ -0,0 +1,186 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + name: listener~80 +Routes: +- ignorePortInHostMatching: true + name: listener~80 + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /foo + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-httproute + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + header: x-client-id + hideCredentials: true + keySources: + - header: x-api-key + - match: + pathSeparatedPrefix: /bar + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-httproute + name: listener~80~example_com-route-1-httproute-example-route-default-1-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + header: x-client-id + hideCredentials: true + keySources: + - header: x-api-key +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-httproute: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml new file mode 100644 index 00000000000..0c5cab1c4a2 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml @@ -0,0 +1,168 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + name: listener~80 +Routes: +- ignorePortInHostMatching: true + name: listener~80 + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /foo + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-route + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + header: x-client-id + hideCredentials: true + keySources: + - header: x-api-key + - match: + pathSeparatedPrefix: /bar + name: listener~80~example_com-route-1-httproute-example-route-default-1-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-route: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/test/e2e/features/apikeyauth/suite.go b/test/e2e/features/apikeyauth/suite.go new file mode 100644 index 00000000000..055bf778802 --- /dev/null +++ b/test/e2e/features/apikeyauth/suite.go @@ -0,0 +1,424 @@ +//go:build e2e + +package apikeyauth + +import ( + "context" + + "github.com/stretchr/testify/suite" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kgateway-dev/kgateway/v2/pkg/utils/kubeutils" + "github.com/kgateway-dev/kgateway/v2/pkg/utils/requestutils/curl" + "github.com/kgateway-dev/kgateway/v2/test/e2e" + testdefaults "github.com/kgateway-dev/kgateway/v2/test/e2e/defaults" + "github.com/kgateway-dev/kgateway/v2/test/e2e/tests/base" +) + +var _ e2e.NewSuiteFunc = NewTestingSuite + +// testingSuite is a suite of tests for API key authentication functionality +type testingSuite struct { + *base.BaseTestingSuite +} + +func NewTestingSuite(ctx context.Context, testInst *e2e.TestInstallation) suite.TestingSuite { + return &testingSuite{ + base.NewBaseTestingSuite(ctx, testInst, setup, testCases), + } +} + +// TestAPIKeyAuthWithHTTPRouteLevelPolicy tests API key authentication with TrafficPolicy applied at HTTPRoute level +func (s *testingSuite) TestAPIKeyAuthWithHTTPRouteLevelPolicy() { + // Verify HTTPRoute is accepted before running the test + s.TestInstallation.Assertions.EventuallyHTTPRouteCondition(s.Ctx, "httpbin-route", "default", gwv1.RouteConditionAccepted, metav1.ConditionTrue) + + statusReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/status/200"), + } + // missing API key, should fail + s.T().Log("The /status route has API key auth applied at HTTPRoute level, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key, should succeed + s.T().Log("The /status route has API key auth applied at HTTPRoute level, should succeed when valid API key is present") + statusWithAPIKeyCurlOpts := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithAPIKeyCurlOpts, + expectStatus200Success, + ) + + getReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/get"), + } + // missing API key, should fail + s.T().Log("The /get route has API key auth applied at HTTPRoute level, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key, should succeed + s.T().Log("The /get route has API key auth applied at HTTPRoute level, should succeed when valid API key is present") + getWithAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithAPIKeyCurlOpts, + expectStatus200Success, + ) +} + +// TestAPIKeyAuthWithRouteLevelPolicy tests API key authentication with TrafficPolicy applied at route level (sectionName) +func (s *testingSuite) TestAPIKeyAuthWithRouteLevelPolicy() { + // Verify HTTPRoute is accepted before running the test + s.TestInstallation.Assertions.EventuallyHTTPRouteCondition(s.Ctx, "httpbin-route", "default", gwv1.RouteConditionAccepted, metav1.ConditionTrue) + + statusReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/status/200"), + } + // missing API key, no API key auth on route, should succeed + s.T().Log("The /status route has no API key auth policy") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusReqCurlOpts, + expectStatus200Success, + ) + + getReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/get"), + } + // missing API key, should fail + s.T().Log("The /get route has an API key auth policy applied at the route level, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key, should succeed + s.T().Log("The /get route has an API key auth policy applied at the route level, should succeed when valid API key is present") + getWithAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithAPIKeyCurlOpts, + expectStatus200Success, + ) + // has invalid API key, should fail + s.T().Log("The /get route has an API key auth policy applied at the route level, should fail when invalid API key is present") + getWithInvalidAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "invalid-key")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithInvalidAPIKeyCurlOpts, + expectAPIKeyAuthDenied, + ) +} + +// TestAPIKeyAuthWithQueryParameter tests API key authentication using query parameter as the key source +func (s *testingSuite) TestAPIKeyAuthWithQueryParameter() { + // Verify HTTPRoute is accepted before running the test + s.TestInstallation.Assertions.EventuallyHTTPRouteCondition(s.Ctx, "httpbin-route-query", "default", gwv1.RouteConditionAccepted, metav1.ConditionTrue) + + statusReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/status/200"), + } + // missing API key, should fail + s.T().Log("The /status route has API key auth with query parameter, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key in query parameter, should succeed + s.T().Log("The /status route has API key auth with query parameter, should succeed when valid API key is present in query") + statusWithAPIKeyCurlOpts := append(statusReqCurlOpts, curl.WithQueryParameters(map[string]string{"api-key": "k-123"})) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithAPIKeyCurlOpts, + expectStatus200Success, + ) + + getReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/get"), + } + // missing API key, should fail + s.T().Log("The /get route has API key auth with query parameter, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key in query parameter, should succeed + s.T().Log("The /get route has API key auth with query parameter, should succeed when valid API key is present in query") + getWithAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithQueryParameters(map[string]string{"api-key": "k-123"})) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithAPIKeyCurlOpts, + expectStatus200Success, + ) + // has invalid API key in query parameter, should fail + s.T().Log("The /get route has API key auth with query parameter, should fail when invalid API key is present in query") + getWithInvalidAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithQueryParameters(map[string]string{"api-key": "invalid-key"})) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithInvalidAPIKeyCurlOpts, + expectAPIKeyAuthDenied, + ) +} + +// TestAPIKeyAuthWithCookie tests API key authentication using cookie as the key source +func (s *testingSuite) TestAPIKeyAuthWithCookie() { + // Verify HTTPRoute is accepted before running the test + s.TestInstallation.Assertions.EventuallyHTTPRouteCondition(s.Ctx, "httpbin-route-cookie", "default", gwv1.RouteConditionAccepted, metav1.ConditionTrue) + + statusReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/status/200"), + } + // missing API key, should fail + s.T().Log("The /status route has API key auth with cookie, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key in cookie, should succeed + s.T().Log("The /status route has API key auth with cookie, should succeed when valid API key is present in cookie") + statusWithAPIKeyCurlOpts := append(statusReqCurlOpts, curl.WithCookie("api-key=k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithAPIKeyCurlOpts, + expectStatus200Success, + ) + + getReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/get"), + } + // missing API key, should fail + s.T().Log("The /get route has API key auth with cookie, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key in cookie, should succeed + s.T().Log("The /get route has API key auth with cookie, should succeed when valid API key is present in cookie") + getWithAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithCookie("api-key=k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithAPIKeyCurlOpts, + expectStatus200Success, + ) + // has invalid API key in cookie, should fail + s.T().Log("The /get route has API key auth with cookie, should fail when invalid API key is present in cookie") + getWithInvalidAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithCookie("api-key=invalid-key")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithInvalidAPIKeyCurlOpts, + expectAPIKeyAuthDenied, + ) +} + +// TestAPIKeyAuthWithSecretUpdate tests that API key authentication correctly handles secret updates +func (s *testingSuite) TestAPIKeyAuthWithSecretUpdate() { + // Verify HTTPRoute is accepted before running the test + s.TestInstallation.Assertions.EventuallyHTTPRouteCondition(s.Ctx, "httpbin-route-secret-update", "default", gwv1.RouteConditionAccepted, metav1.ConditionTrue) + + statusReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/status/200"), + } + + // Step 1: Verify initial API keys work (k-123, k-456) + s.T().Log("Step 1: Verifying initial API keys (k-123, k-456) work") + statusWithK123 := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK123, + expectStatus200Success, + ) + statusWithK456 := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-456")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK456, + expectStatus200Success, + ) + + // Step 2: Update the secret with new keys (k-789, k-999) and remove old ones + // Note: kubectl apply with stringData merges with existing data, so we need to delete and recreate + // to properly replace the secret content + s.T().Log("Step 2: Updating secret with new API keys (k-789, k-999)") + err := s.TestInstallation.Actions.Kubectl().Delete(s.Ctx, []byte(`apiVersion: v1 +kind: Secret +metadata: + name: api-keys-secret-update + namespace: default +`)) + s.Require().NoError(err, "failed to delete old secret") + + updatedSecretYAML := `apiVersion: v1 +kind: Secret +metadata: + name: api-keys-secret-update + namespace: default +type: Opaque +stringData: + client3: k-789 + client4: k-999 +` //gosec:disable G101 + err = s.TestInstallation.Actions.Kubectl().Apply(s.Ctx, []byte(updatedSecretYAML)) + s.Require().NoError(err, "failed to apply updated secret") + + // Wait for secret update to propagate through KRT and Envoy config regeneration + // The KRT framework should detect the secret change and trigger filter config regeneration + // Using Eventually to wait for the new keys to work, which confirms the update propagated + s.T().Log("Waiting for secret update to propagate...") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + append(statusReqCurlOpts, curl.WithHeader("api-key", "k-789")), + expectStatus200Success, + ) + + // Step 3: Verify new keys work + s.T().Log("Step 3: Verifying new API keys (k-789, k-999) work") + statusWithK789 := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-789")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK789, + expectStatus200Success, + ) + statusWithK999 := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-999")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK999, + expectStatus200Success, + ) + + // Step 4: Verify old keys no longer work + s.T().Log("Step 4: Verifying old API keys (k-123, k-456) no longer work") + statusWithK123Old := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK123Old, + expectAPIKeyAuthDenied, + ) + statusWithK456Old := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-456")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK456Old, + expectAPIKeyAuthDenied, + ) + + // Step 5: Update secret again to add back an old key and add a new one + // Note: kubectl apply with stringData merges with existing data + // The secret will now have: client1 (k-123), client3 (k-789), client4 (k-999), client5 (k-111) + s.T().Log("Step 5: Updating secret again to add back k-123 and add k-111") + finalSecretYAML := `apiVersion: v1 +kind: Secret +metadata: + name: api-keys-secret-update + namespace: default +type: Opaque +stringData: + client1: k-123 + client5: k-111 +` //gosec:disable G101 + err = s.TestInstallation.Actions.Kubectl().Apply(s.Ctx, []byte(finalSecretYAML)) + s.Require().NoError(err, "failed to apply final secret update") + + // Step 6: Verify the final set of keys work + // After Step 5 merge update, the secret has: client1 (k-123), client3 (k-789), client4 (k-999), client5 (k-111) + s.T().Log("Step 6: Verifying final API keys (k-123, k-789, k-999, k-111) work") + statusWithK123Final := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK123Final, + expectStatus200Success, + ) + statusWithK789Final := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-789")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK789Final, + expectStatus200Success, + ) + statusWithK999Final := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-999")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK999Final, + expectStatus200Success, + ) + statusWithK111Final := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-111")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK111Final, + expectStatus200Success, + ) + + // Step 7: Verify removed keys no longer work + // k-456 was removed in Step 2, so it should not work + s.T().Log("Step 7: Verifying removed API key (k-456) no longer work") + statusWithK456Removed := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-456")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithK456Removed, + expectAPIKeyAuthDenied, + ) +} diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml new file mode 100644 index 00000000000..e9516cd6f99 --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin-route-cookie + namespace: default +spec: + hostnames: + - httpbin + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + namespace: default + rules: + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /status/200 + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /get +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-cookie + namespace: default +type: Opaque +stringData: + client1: k-123 + client2: k-456 +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-policy-cookie +spec: + targetRefs: + - name: httpbin-route-cookie + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - cookie: api-key + hideAPIKey: false + secretRef: + name: api-keys-cookie + diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml new file mode 100644 index 00000000000..5afe2a8aa93 --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin-route-query + namespace: default +spec: + hostnames: + - httpbin + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + namespace: default + rules: + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /status/200 + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /get +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-query + namespace: default +type: Opaque +stringData: + client1: k-123 + client2: k-456 +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-policy-query +spec: + targetRefs: + - name: httpbin-route-query + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - query: api-key + hideAPIKey: false + secretRef: + name: api-keys-query + diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml new file mode 100644 index 00000000000..199a65e1b05 --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml @@ -0,0 +1,52 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin-route-secret-update + namespace: default +spec: + hostnames: + - httpbin + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + namespace: default + rules: + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /status/200 +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-secret-update + namespace: default +type: Opaque +stringData: + client1: k-123 + client2: k-456 +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-policy-secret-update +spec: + targetRefs: + - name: httpbin-route-secret-update + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - header: "api-key" + hideAPIKey: false + secretRef: + name: api-keys-secret-update + diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml new file mode 100644 index 00000000000..8100c2bd43c --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin-route + namespace: default +spec: + hostnames: + - httpbin + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + namespace: default + rules: + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /status/200 + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + name: httpbin-get + matches: + - path: + type: PathPrefix + value: /get +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: default +type: Opaque +stringData: + client1: k-123 + client2: k-456 +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-policy +spec: + targetRefs: + - name: httpbin-route + kind: HTTPRoute + group: gateway.networking.k8s.io + sectionName: httpbin-get + apiKeyAuthentication: + keySources: + - header: "api-key" + hideAPIKey: false + secretRef: + name: api-keys + diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml new file mode 100644 index 00000000000..d331fdd31d6 --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin-route + namespace: default +spec: + hostnames: + - httpbin + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + namespace: default + rules: + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /status/200 + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /get +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: default +type: Opaque +stringData: + client1: k-123 + client2: k-456 +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-policy +spec: + targetRefs: + - name: httpbin-route + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - header: "api-key" + hideAPIKey: false + secretRef: + name: api-keys + diff --git a/test/e2e/features/apikeyauth/testdata/setup.yaml b/test/e2e/features/apikeyauth/testdata/setup.yaml new file mode 100644 index 00000000000..e0847a144ed --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/setup.yaml @@ -0,0 +1,14 @@ +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: gw +spec: + gatewayClassName: kgateway + listeners: + - protocol: HTTP + port: 8080 + name: http + allowedRoutes: + namespaces: + from: All + diff --git a/test/e2e/features/apikeyauth/types.go b/test/e2e/features/apikeyauth/types.go new file mode 100644 index 00000000000..73d71da34d0 --- /dev/null +++ b/test/e2e/features/apikeyauth/types.go @@ -0,0 +1,67 @@ +//go:build e2e + +package apikeyauth + +import ( + "net/http" + "path/filepath" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kgateway-dev/kgateway/v2/pkg/utils/fsutils" + "github.com/kgateway-dev/kgateway/v2/test/e2e/defaults" + "github.com/kgateway-dev/kgateway/v2/test/e2e/tests/base" + "github.com/kgateway-dev/kgateway/v2/test/gomega/matchers" +) + +var ( + // manifests + setupManifest = filepath.Join(fsutils.MustGetThisDir(), "testdata", "setup.yaml") + apiKeyAuthManifest = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth.yaml") + apiKeyAuthManifestWithSection = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-section.yaml") + apiKeyAuthManifestQuery = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-query.yaml") + apiKeyAuthManifestCookie = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-cookie.yaml") + apiKeyAuthManifestSecretUpdate = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-secret-update.yaml") + // Core infrastructure objects that we need to track + gatewayObjectMeta = metav1.ObjectMeta{ + Name: "gw", + Namespace: "default", + } + gatewayService = &corev1.Service{ObjectMeta: gatewayObjectMeta} + + expectStatus200Success = &matchers.HttpResponse{ + StatusCode: http.StatusOK, + Body: nil, + } + expectAPIKeyAuthDenied = &matchers.HttpResponse{ + StatusCode: http.StatusUnauthorized, + Body: nil, + } + + commonSetupManifests = []string{defaults.HttpbinManifest, defaults.CurlPodManifest} + // Base test setup - common infrastructure for all tests + setup = base.TestCase{ + Manifests: append([]string{setupManifest}, commonSetupManifests...), + } + + // Individual test cases - test-specific manifests and resources + testCases = map[string]*base.TestCase{ + "TestAPIKeyAuthWithRouteLevelPolicy": { + Manifests: []string{apiKeyAuthManifestWithSection}, + MinGwApiVersion: base.GwApiRequireRouteNames, + }, + "TestAPIKeyAuthWithHTTPRouteLevelPolicy": { + Manifests: []string{apiKeyAuthManifest}, + }, + "TestAPIKeyAuthWithQueryParameter": { + Manifests: []string{apiKeyAuthManifestQuery}, + }, + "TestAPIKeyAuthWithCookie": { + Manifests: []string{apiKeyAuthManifestCookie}, + }, + "TestAPIKeyAuthWithSecretUpdate": { + Manifests: []string{apiKeyAuthManifestSecretUpdate}, + }, + } +) diff --git a/test/e2e/tests/kgateway_tests.go b/test/e2e/tests/kgateway_tests.go index 23c4395fb53..6d6f7cdafff 100644 --- a/test/e2e/tests/kgateway_tests.go +++ b/test/e2e/tests/kgateway_tests.go @@ -6,6 +6,7 @@ import ( "github.com/kgateway-dev/kgateway/v2/test/e2e" "github.com/kgateway-dev/kgateway/v2/test/e2e/features/accesslog" "github.com/kgateway-dev/kgateway/v2/test/e2e/features/admin_server" + "github.com/kgateway-dev/kgateway/v2/test/e2e/features/apikeyauth" "github.com/kgateway-dev/kgateway/v2/test/e2e/features/auto_host_rewrite" "github.com/kgateway-dev/kgateway/v2/test/e2e/features/backendconfigpolicy" "github.com/kgateway-dev/kgateway/v2/test/e2e/features/backends" @@ -76,6 +77,7 @@ func KubeGatewaySuiteRunner() e2e.SuiteRunner { kubeGatewaySuiteRunner.Register("TimeoutRetry", timeoutretry.NewTestingSuite) kubeGatewaySuiteRunner.Register("HeaderModifiers", header_modifiers.NewTestingSuite) kubeGatewaySuiteRunner.Register("RBAC", rbac.NewTestingSuite) + kubeGatewaySuiteRunner.Register("APIKeyAuth", apikeyauth.NewTestingSuite) kubeGatewaySuiteRunner.Register("AdminServer", admin_server.NewTestingSuite) kubeGatewaySuiteRunner.Register("JWT", jwt.NewTestingSuite) From d16cab0f28ff343e8792e7caf6fceaa49b69e632 Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Mon, 24 Nov 2025 16:43:10 +0000 Subject: [PATCH 02/11] Lint fix Signed-off-by: Yossi Mesika --- .../extensions2/plugins/trafficpolicy/traffic_policy_plugin.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go b/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go index dbd4625c93b..6ba846485b2 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go @@ -15,7 +15,6 @@ import ( localratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/local_ratelimit/v3" envoyrbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" envoy_wellknown "github.com/envoyproxy/go-control-plane/pkg/wellknown" - // TODO(nfuden): remove once rustformations are able to be used in a production environment transformationpb "github.com/solo-io/envoy-gloo/go/config/filter/http/transformation/v2" "google.golang.org/protobuf/types/known/anypb" From 7d8b1b869b025f22f30af943e5998f6d9224b9dc Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Mon, 24 Nov 2025 16:49:20 +0000 Subject: [PATCH 03/11] Copilot review comments addressed Signed-off-by: Yossi Mesika --- api/v1alpha1/traffic_policy_types.go | 4 ++-- .../templates/gateway.kgateway.dev_trafficpolicies.yaml | 4 ++-- test/e2e/features/apikeyauth/suite.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index ea0b706b939..75884f8dd6b 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -446,7 +446,7 @@ type APIKeyAuthentication struct { // metadata: // name: api-key // stringData: - // client1: "k-123", + // client1: "k-123" // client2: "k-456" // // +optional @@ -465,7 +465,7 @@ type APIKeyAuthentication struct { // metadata: // name: api-key // stringData: - // client1: "k-123", + // client1: "k-123" // client2: "k-456" // // +optional diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index 8bfd7da3ea0..78eb2d3cf59 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -135,7 +135,7 @@ spec: metadata: name: api-key stringData: - client1: "k-123", + client1: "k-123" client2: "k-456" properties: name: @@ -164,7 +164,7 @@ spec: metadata: name: api-key stringData: - client1: "k-123", + client1: "k-123" client2: "k-456" properties: matchLabels: diff --git a/test/e2e/features/apikeyauth/suite.go b/test/e2e/features/apikeyauth/suite.go index 055bf778802..54a14956bab 100644 --- a/test/e2e/features/apikeyauth/suite.go +++ b/test/e2e/features/apikeyauth/suite.go @@ -381,7 +381,7 @@ stringData: // Step 6: Verify the final set of keys work // After Step 5 merge update, the secret has: client1 (k-123), client3 (k-789), client4 (k-999), client5 (k-111) - s.T().Log("Step 6: Verifying final API keys (k-123, k-789, k-999, k-111) work") + s.T().Log("Step 6: Verifying final API keys (k-123, k-789, k-999, k-111) works") statusWithK123Final := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-123")) s.TestInstallation.Assertions.AssertEventualCurlResponse( s.Ctx, From 1699f45ac733ceeeddb70b924de66cb4de952e0a Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Mon, 24 Nov 2025 17:07:51 +0000 Subject: [PATCH 04/11] Golden refreshed Signed-off-by: Yossi Mesika --- .../outputs/traffic-policy/api-key-auth-gateway.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml index 1d086a5a22f..60a1605780b 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml @@ -58,10 +58,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client2 - key: k-456 - client: client1 key: k-123 + - client: client2 + key: k-456 forwarding: header: x-client-id keySources: From 8718f8edcab3de21d5ef8f0f60bb95a8fbc1d06d Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Tue, 25 Nov 2025 08:26:21 +0000 Subject: [PATCH 05/11] Sort test output credentials for consistency Signed-off-by: Yossi Mesika --- test/translator/test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/translator/test.go b/test/translator/test.go index 82174fddbe4..70f2e55c5c6 100644 --- a/test/translator/test.go +++ b/test/translator/test.go @@ -14,6 +14,7 @@ import ( envoyclusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" envoylistenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" envoyroutev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + envoyapikeyauthv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/api_key_auth/v3" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" @@ -321,7 +322,12 @@ func compareProxy(expectedFile string, actualProxy *irtranslator.TranslationResu return "", err } - return cmp.Diff(sortProxy(expectedProxy), sortProxy(actualProxy), protocmp.Transform(), cmpopts.EquateNaNs()), nil + // Sort credentials by client name to ensure deterministic comparison + credentialSortFn := func(x, y *envoyapikeyauthv3.Credential) bool { + return x.Client < y.Client + } + + return cmp.Diff(sortProxy(expectedProxy), sortProxy(actualProxy), protocmp.Transform(), protocmp.SortRepeated(credentialSortFn), cmpopts.EquateNaNs()), nil } func sortProxy(proxy *irtranslator.TranslationResult) *irtranslator.TranslationResult { From fa97d6229577d65d46f85a4b51cd536a56625196 Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Tue, 25 Nov 2025 11:50:49 +0000 Subject: [PATCH 06/11] Add a configurable client id header and simplify code Signed-off-by: Yossi Mesika --- api/v1alpha1/traffic_policy_types.go | 6 + api/v1alpha1/zz_generated.deepcopy.go | 5 + .../gateway.kgateway.dev_trafficpolicies.yaml | 6 + .../plugins/trafficpolicy/api_key_auth.go | 49 ++++----- .../trafficpolicy/api_key_auth_test.go | 103 +++--------------- .../traffic-policy/api-key-auth-route.yaml | 1 + .../traffic-policy/api-key-auth-route.yaml | 2 +- 7 files changed, 54 insertions(+), 118 deletions(-) diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index 75884f8dd6b..79ebee77f31 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -433,6 +433,12 @@ type APIKeyAuthentication struct { // +optional HideAPIKey *bool `json:"hideAPIKey,omitempty"` + // clientIdHeader specifies the header name to forward the authenticated client identifier. + // If not specified, defaults to "x-client-id". + // +kubebuilder:default="x-client-id" + // +optional + ClientIdHeader *string `json:"clientIdHeader,omitempty"` + // secretRef references a Kubernetes secret storing a set of API Keys. If there are many keys, 'secretSelector' can be // used instead. // diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8a150318735..0eb1647efc6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -108,6 +108,11 @@ func (in *APIKeyAuthentication) DeepCopyInto(out *APIKeyAuthentication) { *out = new(bool) **out = **in } + if in.ClientIdHeader != nil { + in, out := &in.ClientIdHeader, &out.ClientIdHeader + *out = new(string) + **out = **in + } if in.SecretRef != nil { in, out := &in.SecretRef, &out.SecretRef *out = new(corev1.LocalObjectReference) diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index 78eb2d3cf59..22f2d52e182 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -58,6 +58,12 @@ spec: description: APIKeyAuthentication authenticates users based on a configured API Key. properties: + clientIdHeader: + default: x-client-id + description: |- + clientIdHeader specifies the header name to forward the authenticated client identifier. + If not specified, defaults to "x-client-id". + type: string hideAPIKey: default: false description: |- diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go index 0d4a0aaacf5..4ad738ccaf5 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -19,7 +19,7 @@ const ( // apiKeyAuthIR is the internal representation of an API key authentication policy. type apiKeyAuthIR struct { - config *envoyapikeyauthv3.ApiKeyAuth + config *envoyapikeyauthv3.ApiKeyAuthPerRoute } func (a *apiKeyAuthIR) Equals(other *apiKeyAuthIR) bool { @@ -50,7 +50,7 @@ func (a *apiKeyAuthIR) Validate() error { return a.config.Validate() } -// constructAPIKeyAuth translates the API key authentication spec into an Envoy API key auth filter configuration +// constructAPIKeyAuth translates the API key authentication spec into an Envoy API key auth per-route configuration func constructAPIKeyAuth( krtctx krt.HandlerContext, policy *v1alpha1.TrafficPolicy, @@ -171,33 +171,32 @@ func constructAPIKeyAuth( hideCredentials = *ak.HideAPIKey } - // Build Envoy API key auth filter configuration - apiKeyAuthConfig := &envoyapikeyauthv3.ApiKeyAuth{ + // Determine client ID header (default to "x-client-id") + clientIdHeader := "x-client-id" + if ak.ClientIdHeader != nil { + clientIdHeader = *ak.ClientIdHeader + } + + // Build Envoy API key auth per-route configuration + apiKeyAuthPolicy := &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: credentials, KeySources: envoyKeySources, Forwarding: &envoyapikeyauthv3.Forwarding{ - Header: "x-client-id", + Header: clientIdHeader, HideCredentials: hideCredentials, }, } out.apiKeyAuth = &apiKeyAuthIR{ - config: apiKeyAuthConfig, + config: apiKeyAuthPolicy, } return nil } // handleAPIKeyAuth configures the API key auth filter and per-route API key auth configuration. -// This follows the same pattern as RBAC: add an empty filter to the chain and put the actual config -// in the typedPerFilterConfig. The per-route config will be applied at RouteConfiguration level for -// gateway-level policies, and at Route level for route-level policies (which will override the -// RouteConfiguration level config). -// -// IMPORTANT: For route-level-only policies (no gateway-level policy), we add FilterConfig with -// disabled: true at the RouteConfiguration level in ApplyRouteConfigPlugin. This disables the filter -// for all routes by default. Routes with policies will override this with ApiKeyAuthPerRoute, which -// enables the filter for those specific routes. +// This follows the same pattern as CORS: add the policy to the typed_per_filter_config. +// Also requires API key auth http_filter to be added to the filter chain. func (p *trafficPolicyPluginGwPass) handleAPIKeyAuth( fcn string, pCtxTypedFilterConfig *ir.TypedFilterConfigMap, @@ -207,24 +206,16 @@ func (p *trafficPolicyPluginGwPass) handleAPIKeyAuth( return } - // Always add the filter to the chain if not already present. - // For route-level-only policies, it will be disabled at RouteConfiguration level, - // and enabled per-route via ApiKeyAuthPerRoute for routes with policies. + // Adds the ApiKeyAuthPerRoute to the typed_per_filter_config. + // Also requires API key auth http_filter to be added to the filter chain. + pCtxTypedFilterConfig.AddTypedConfig(apiKeyAuthFilterNamePrefix, apiKeyAuthIr.config) + + // Add a filter to the chain. When having an api key auth policy for a route we need to also have a + // globally api key auth http filter in the chain otherwise it will be ignored. if p.apiKeyAuthInChain == nil { p.apiKeyAuthInChain = make(map[string]*envoyapikeyauthv3.ApiKeyAuth) } if _, ok := p.apiKeyAuthInChain[fcn]; !ok { p.apiKeyAuthInChain[fcn] = &envoyapikeyauthv3.ApiKeyAuth{} } - - // Always add the per-route API key auth configuration to the typed filter config - // This will be applied at RouteConfiguration level for gateway-level policies, - // and at Route level for route-level policies (overriding RouteConfiguration level) - perRouteConfig := &envoyapikeyauthv3.ApiKeyAuthPerRoute{ - Credentials: apiKeyAuthIr.config.Credentials, - KeySources: apiKeyAuthIr.config.KeySources, - Forwarding: apiKeyAuthIr.config.Forwarding, - } - - pCtxTypedFilterConfig.AddTypedConfig(apiKeyAuthFilterNamePrefix, perRouteConfig) } diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go index e5214379a43..53e6e6ff42b 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go @@ -6,15 +6,14 @@ import ( envoyapikeyauthv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/api_key_auth/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk/ir" ) func TestAPIKeyAuthIREquals(t *testing.T) { // Helper to create simple API key auth configurations for testing - createAPIKeyAuth := func(headerName string, hideCredentials bool) *envoyapikeyauthv3.ApiKeyAuth { - return &envoyapikeyauthv3.ApiKeyAuth{ + createAPIKeyAuth := func(headerName string, hideCredentials bool) *envoyapikeyauthv3.ApiKeyAuthPerRoute { + return &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ { Key: "test-key", @@ -58,7 +57,7 @@ func TestAPIKeyAuthIREquals(t *testing.T) { expected: false, }, { - name: "both config nil are equal", + name: "both policy nil are equal", apiKeyAuth1: &apiKeyAuthIR{config: nil}, apiKeyAuth2: &apiKeyAuthIR{config: nil}, expected: true, @@ -84,14 +83,14 @@ func TestAPIKeyAuthIREquals(t *testing.T) { { name: "different credentials are not equal", apiKeyAuth1: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ {Key: "key1", Client: "client1"}, }, }, }, apiKeyAuth2: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ {Key: "key2", Client: "client2"}, }, @@ -102,14 +101,14 @@ func TestAPIKeyAuthIREquals(t *testing.T) { { name: "same credentials are equal", apiKeyAuth1: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ {Key: "key1", Client: "client1"}, }, }, }, apiKeyAuth2: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ {Key: "key1", Client: "client1"}, }, @@ -160,14 +159,14 @@ func TestAPIKeyAuthIRValidate(t *testing.T) { wantErr: false, }, { - name: "nil config validates successfully", + name: "nil policy validates successfully", apiKeyAuth: &apiKeyAuthIR{config: nil}, wantErr: false, }, { - name: "valid config validates successfully", + name: "valid policy validates successfully", apiKeyAuth: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ { Key: "test-key", @@ -188,9 +187,9 @@ func TestAPIKeyAuthIRValidate(t *testing.T) { wantErr: false, }, { - name: "config with empty credentials validates successfully", + name: "policy with empty credentials validates successfully", apiKeyAuth: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{}, KeySources: []*envoyapikeyauthv3.KeySource{ { @@ -229,15 +228,15 @@ func TestHandleAPIKeyAuth(t *testing.T) { expectRoute: false, }, { - name: "nil config does nothing", + name: "nil policy does nothing", apiKeyAuthIr: &apiKeyAuthIR{config: nil}, expectChain: false, expectRoute: false, }, { - name: "valid config adds to chain and route", + name: "valid policy adds to chain and route", apiKeyAuthIr: &apiKeyAuthIR{ - config: &envoyapikeyauthv3.ApiKeyAuth{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: []*envoyapikeyauthv3.Credential{ { Key: "test-key", @@ -292,75 +291,3 @@ func TestHandleAPIKeyAuth(t *testing.T) { }) } } - -func TestHandleAPIKeyAuth_MultipleCalls(t *testing.T) { - plugin := &trafficPolicyPluginGwPass{ - apiKeyAuthInChain: make(map[string]*envoyapikeyauthv3.ApiKeyAuth), - } - fcn := "test-filter-chain" - typedFilterConfig := &ir.TypedFilterConfigMap{} - - config := &envoyapikeyauthv3.ApiKeyAuth{ - Credentials: []*envoyapikeyauthv3.Credential{ - { - Key: "test-key", - Client: "test-client", - }, - }, - KeySources: []*envoyapikeyauthv3.KeySource{ - { - Header: "api-key", - }, - }, - } - - apiKeyAuthIr := &apiKeyAuthIR{config: config} - - // First call - plugin.handleAPIKeyAuth(fcn, typedFilterConfig, apiKeyAuthIr) - assert.NotNil(t, plugin.apiKeyAuthInChain[fcn], "first call should add to chain") - - // Second call with same config should not overwrite - plugin.handleAPIKeyAuth(fcn, typedFilterConfig, apiKeyAuthIr) - assert.NotNil(t, plugin.apiKeyAuthInChain[fcn], "second call should not remove from chain") - - // Verify the config is the same (not overwritten) - assert.True(t, proto.Equal(config, plugin.apiKeyAuthInChain[fcn]), "config should not be overwritten") -} - -func TestHandleAPIKeyAuth_DifferentFilterChains(t *testing.T) { - plugin := &trafficPolicyPluginGwPass{ - apiKeyAuthInChain: make(map[string]*envoyapikeyauthv3.ApiKeyAuth), - } - - config1 := &envoyapikeyauthv3.ApiKeyAuth{ - Credentials: []*envoyapikeyauthv3.Credential{ - {Key: "key1", Client: "client1"}, - }, - KeySources: []*envoyapikeyauthv3.KeySource{ - {Header: "api-key"}, - }, - } - - config2 := &envoyapikeyauthv3.ApiKeyAuth{ - Credentials: []*envoyapikeyauthv3.Credential{ - {Key: "key2", Client: "client2"}, - }, - KeySources: []*envoyapikeyauthv3.KeySource{ - {Header: "x-api-key"}, - }, - } - - fcn1 := "filter-chain-1" - fcn2 := "filter-chain-2" - typedFilterConfig1 := &ir.TypedFilterConfigMap{} - typedFilterConfig2 := &ir.TypedFilterConfigMap{} - - plugin.handleAPIKeyAuth(fcn1, typedFilterConfig1, &apiKeyAuthIR{config: config1}) - plugin.handleAPIKeyAuth(fcn2, typedFilterConfig2, &apiKeyAuthIR{config: config2}) - - assert.NotNil(t, plugin.apiKeyAuthInChain[fcn1], "should add config for chain 1") - assert.NotNil(t, plugin.apiKeyAuthInChain[fcn2], "should add config for chain 2") - assert.True(t, proto.Equal(config1, plugin.apiKeyAuthInChain[fcn1]), "chain 1 should have correct config") - assert.True(t, proto.Equal(config2, plugin.apiKeyAuthInChain[fcn2]), "chain 2 should have correct config") -} diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml index 4cf6845c529..6ed8fd27d14 100644 --- a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml @@ -65,6 +65,7 @@ spec: keySources: - header: "x-api-key" hideAPIKey: true + clientIdHeader: "x-authenticated-client" secretRef: name: api-keys --- diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml index 0c5cab1c4a2..86bffd12ae9 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml @@ -69,7 +69,7 @@ Routes: - client: client2 key: k-456 forwarding: - header: x-client-id + header: x-authenticated-client hideCredentials: true keySources: - header: x-api-key From 43bdc0d0eb06947559f6ad6279939fcf4f00a745 Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Tue, 25 Nov 2025 16:02:57 +0200 Subject: [PATCH 07/11] Allow empty ClientIdHeader Signed-off-by: Yossi Mesika --- api/v1alpha1/traffic_policy_types.go | 4 ++-- .../gateway.kgateway.dev_trafficpolicies.yaml | 4 ++-- .../plugins/trafficpolicy/api_key_auth.go | 12 +++++----- .../trafficpolicy/api_key_auth_test.go | 22 +++++++++++++++++++ .../traffic-policy/api-key-auth-gateway.yaml | 3 +-- .../api-key-auth-httproute.yaml | 10 ++++----- .../apikeyauth/testdata/api-key-auth.yaml | 1 + 7 files changed, 37 insertions(+), 19 deletions(-) diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index 79ebee77f31..77ca87c38d9 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -434,8 +434,8 @@ type APIKeyAuthentication struct { HideAPIKey *bool `json:"hideAPIKey,omitempty"` // clientIdHeader specifies the header name to forward the authenticated client identifier. - // If not specified, defaults to "x-client-id". - // +kubebuilder:default="x-client-id" + // If not specified, the client identifier will not be forwarded in any header. + // Example: "x-client-id" // +optional ClientIdHeader *string `json:"clientIdHeader,omitempty"` diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index 22f2d52e182..77061252cf2 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -59,10 +59,10 @@ spec: API Key. properties: clientIdHeader: - default: x-client-id description: |- clientIdHeader specifies the header name to forward the authenticated client identifier. - If not specified, defaults to "x-client-id". + If not specified, the client identifier will not be forwarded in any header. + Example: "x-client-id" type: string hideAPIKey: default: false diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go index 4ad738ccaf5..1480d0dda18 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -171,22 +171,20 @@ func constructAPIKeyAuth( hideCredentials = *ak.HideAPIKey } - // Determine client ID header (default to "x-client-id") - clientIdHeader := "x-client-id" - if ak.ClientIdHeader != nil { - clientIdHeader = *ak.ClientIdHeader - } - // Build Envoy API key auth per-route configuration apiKeyAuthPolicy := &envoyapikeyauthv3.ApiKeyAuthPerRoute{ Credentials: credentials, KeySources: envoyKeySources, Forwarding: &envoyapikeyauthv3.Forwarding{ - Header: clientIdHeader, HideCredentials: hideCredentials, }, } + // Only set client ID header forwarding if ClientIdHeader is specified + if ak.ClientIdHeader != nil { + apiKeyAuthPolicy.Forwarding.Header = *ak.ClientIdHeader + } + out.apiKeyAuth = &apiKeyAuthIR{ config: apiKeyAuthPolicy, } diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go index 53e6e6ff42b..aab9a12246f 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go @@ -200,6 +200,28 @@ func TestAPIKeyAuthIRValidate(t *testing.T) { }, wantErr: false, }, + { + name: "policy with no client ID header forwarding validates successfully", + apiKeyAuth: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + Credentials: []*envoyapikeyauthv3.Credential{ + { + Key: "test-key", + Client: "test-client", + }, + }, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + }, + Forwarding: &envoyapikeyauthv3.Forwarding{ + HideCredentials: false, + }, + }, + }, + wantErr: false, + }, } for _, tt := range tests { diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml index 60a1605780b..375fa5e2e2c 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml @@ -62,8 +62,7 @@ Routes: key: k-123 - client: client2 key: k-456 - forwarding: - header: x-client-id + forwarding: {} keySources: - cookie: api-key-cookie header: api-key diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml index 87c8d7741e9..ff4dfc68f97 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml @@ -64,12 +64,11 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client1 - key: k-123 - client: client2 key: k-456 + - client: client1 + key: k-123 forwarding: - header: x-client-id hideCredentials: true keySources: - header: x-api-key @@ -88,12 +87,11 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client1 - key: k-123 - client: client2 key: k-456 + - client: client1 + key: k-123 forwarding: - header: x-client-id hideCredentials: true keySources: - header: x-api-key diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml index d331fdd31d6..582729fa521 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml @@ -57,6 +57,7 @@ spec: keySources: - header: "api-key" hideAPIKey: false + clientIdHeader: "x-client-id" secretRef: name: api-keys From 5877f3fc6b6900a7fc36696cf9f0b2578ab21018 Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Wed, 26 Nov 2025 10:00:58 +0200 Subject: [PATCH 08/11] Change HideAPIKey to ForwardCredential Signed-off-by: Yossi Mesika --- api/v1alpha1/traffic_policy_types.go | 15 ++++++++-- api/v1alpha1/zz_generated.deepcopy.go | 28 +++++++++++++++++-- .../gateway.kgateway.dev_trafficpolicies.yaml | 6 ++-- .../plugins/trafficpolicy/api_key_auth.go | 8 +++--- .../traffic-policy/api-key-auth-gateway.yaml | 2 +- .../api-key-auth-httproute.yaml | 1 - .../traffic-policy/api-key-auth-route.yaml | 2 +- .../testdata/api-key-auth-cookie.yaml | 2 +- .../testdata/api-key-auth-query.yaml | 2 +- .../testdata/api-key-auth-secret-update.yaml | 2 +- .../testdata/api-key-auth-section.yaml | 2 +- .../apikeyauth/testdata/api-key-auth.yaml | 2 +- 12 files changed, 52 insertions(+), 20 deletions(-) diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index 77ca87c38d9..50c6cb11f65 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -427,11 +427,13 @@ type APIKeyAuthentication struct { // +optional KeySources []APIKeySource `json:"keySources,omitempty"` - // hideAPIKey removes the API key from the request before forwarding upstream. + // forwardCredential controls whether the API key is included in the request sent to the upstream. + // If false (default), the API key is removed from the request before sending to upstream. + // If true, the API key is included in the request sent to upstream. // This applies to all configured key sources (header, query parameter, or cookie). // +kubebuilder:default=false // +optional - HideAPIKey *bool `json:"hideAPIKey,omitempty"` + ForwardCredential *bool `json:"forwardCredential,omitempty"` // clientIdHeader specifies the header name to forward the authenticated client identifier. // If not specified, the client identifier will not be forwarded in any header. @@ -475,7 +477,14 @@ type APIKeyAuthentication struct { // client2: "k-456" // // +optional - SecretSelector *SecretSelector `json:"secretSelector,omitempty"` + SecretSelector *LabelSelector `json:"secretSelector,omitempty"` +} + +// LabelSelector selects resources using label selectors. +type LabelSelector struct { + // Label selector to select the target resource. + // +required + MatchLabels map[string]string `json:"matchLabels"` } // HeaderModifiers can be used to define the policy to modify request and response headers. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 0eb1647efc6..107d7a23704 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -103,8 +103,8 @@ func (in *APIKeyAuthentication) DeepCopyInto(out *APIKeyAuthentication) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.HideAPIKey != nil { - in, out := &in.HideAPIKey, &out.HideAPIKey + if in.ForwardCredential != nil { + in, out := &in.ForwardCredential, &out.ForwardCredential *out = new(bool) **out = **in } @@ -120,7 +120,7 @@ func (in *APIKeyAuthentication) DeepCopyInto(out *APIKeyAuthentication) { } if in.SecretSelector != nil { in, out := &in.SecretSelector, &out.SecretSelector - *out = new(SecretSelector) + *out = new(LabelSelector) (*in).DeepCopyInto(*out) } } @@ -4338,6 +4338,28 @@ func (in *LLMProvider) DeepCopy() *LLMProvider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LabelSelector) DeepCopyInto(out *LabelSelector) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LabelSelector. +func (in *LabelSelector) DeepCopy() *LabelSelector { + if in == nil { + return nil + } + out := new(LabelSelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LoadBalancer) DeepCopyInto(out *LoadBalancer) { *out = *in diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index 77061252cf2..43326c80d74 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -64,10 +64,12 @@ spec: If not specified, the client identifier will not be forwarded in any header. Example: "x-client-id" type: string - hideAPIKey: + forwardCredential: default: false description: |- - hideAPIKey removes the API key from the request before forwarding upstream. + forwardCredential controls whether the API key is included in the request sent to the upstream. + If false (default), the API key is removed from the request before sending to upstream. + If true, the API key is included in the request sent to upstream. This applies to all configured key sources (header, query parameter, or cookie). type: boolean keySources: diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go index 1480d0dda18..958db923457 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -165,10 +165,10 @@ func constructAPIKeyAuth( } } - // Determine hide credentials (default to false) - hideCredentials := false - if ak.HideAPIKey != nil { - hideCredentials = *ak.HideAPIKey + // Determine hide credentials (default to true since ForwardCredential defaults to false) + hideCredentials := true + if ak.ForwardCredential != nil { + hideCredentials = !(*ak.ForwardCredential) } // Build Envoy API key auth per-route configuration diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml index f461c94cb6a..4ed4c9e9853 100644 --- a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml @@ -58,7 +58,7 @@ spec: query: "api-key-query" cookie: "api-key-cookie" - header: "api-key-other" - hideAPIKey: false + forwardCredential: true secretRef: name: api-keys --- diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml index ab00bd9464a..18f46057115 100644 --- a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml @@ -62,7 +62,6 @@ spec: apiKeyAuthentication: keySources: - header: "x-api-key" - hideAPIKey: true secretRef: name: api-keys --- diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml index 6ed8fd27d14..b64d0b6c500 100644 --- a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml @@ -64,7 +64,7 @@ spec: apiKeyAuthentication: keySources: - header: "x-api-key" - hideAPIKey: true + forwardCredential: false clientIdHeader: "x-authenticated-client" secretRef: name: api-keys diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml index e9516cd6f99..9f2911bd875 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml @@ -56,7 +56,7 @@ spec: apiKeyAuthentication: keySources: - cookie: api-key - hideAPIKey: false + forwardCredential: true secretRef: name: api-keys-cookie diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml index 5afe2a8aa93..cb8395405e1 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml @@ -56,7 +56,7 @@ spec: apiKeyAuthentication: keySources: - query: api-key - hideAPIKey: false + forwardCredential: true secretRef: name: api-keys-query diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml index 199a65e1b05..dfa4935dbf7 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-secret-update.yaml @@ -46,7 +46,7 @@ spec: apiKeyAuthentication: keySources: - header: "api-key" - hideAPIKey: false + forwardCredential: true secretRef: name: api-keys-secret-update diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml index 8100c2bd43c..c58375d68b9 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-section.yaml @@ -58,7 +58,7 @@ spec: apiKeyAuthentication: keySources: - header: "api-key" - hideAPIKey: false + forwardCredential: true secretRef: name: api-keys diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml index 582729fa521..6b86add3fa4 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml @@ -56,7 +56,7 @@ spec: apiKeyAuthentication: keySources: - header: "api-key" - hideAPIKey: false + forwardCredential: true clientIdHeader: "x-client-id" secretRef: name: api-keys From da8d09d11ef8df82c7582826ac82203f4932c822 Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Thu, 27 Nov 2025 13:45:25 +0200 Subject: [PATCH 09/11] Review comments addressed Signed-off-by: Yossi Mesika --- api/v1alpha1/traffic_policy_types.go | 1 - .../gateway.kgateway.dev_trafficpolicies.yaml | 1 - .../plugins/trafficpolicy/api_key_auth.go | 20 +- .../gateway/gateway_translator_test.go | 22 ++ .../api-key-auth-override-section.yaml | 114 +++++++++ .../traffic-policy/api-key-auth-override.yaml | 111 +++++++++ .../api-key-auth-override-section.yaml | 207 ++++++++++++++++ .../traffic-policy/api-key-auth-override.yaml | 223 ++++++++++++++++++ test/e2e/features/apikeyauth/suite.go | 103 ++++++++ .../testdata/api-key-auth-disable.yaml | 92 ++++++++ test/e2e/features/apikeyauth/types.go | 4 + 11 files changed, 886 insertions(+), 12 deletions(-) create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override-section.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml create mode 100644 test/e2e/features/apikeyauth/testdata/api-key-auth-disable.yaml diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index 3ee24a8329f..530e7c1f16d 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -431,7 +431,6 @@ type APIKeyAuthentication struct { // If false (default), the API key is removed from the request before sending to upstream. // If true, the API key is included in the request sent to upstream. // This applies to all configured key sources (header, query parameter, or cookie). - // +kubebuilder:default=false // +optional ForwardCredential *bool `json:"forwardCredential,omitempty"` diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index 3d8a72aa820..9d6f16b790a 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -65,7 +65,6 @@ spec: Example: "x-client-id" type: string forwardCredential: - default: false description: |- forwardCredential controls whether the API key is included in the request sent to the upstream. If false (default), the API key is removed from the request before sending to upstream. diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go index 958db923457..113e4ba65dc 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -65,7 +65,7 @@ func constructAPIKeyAuth( ak := spec.APIKeyAuthentication // Resolve secrets using SecretIndex - var secrets []*ir.Secret + var secrets []ir.Secret secretGK := schema.GroupKind{Group: "", Kind: "Secret"} secretCol := commoncol.Secrets.GetSecretCollection(secretGK) if secretCol == nil { @@ -84,16 +84,16 @@ func constructAPIKeyAuth( if secret == nil { return fmt.Errorf("API key secret %s not found in namespace %s", ak.SecretRef.Name, policy.Namespace) } - secrets = []*ir.Secret{secret} + secrets = []ir.Secret{*secret} } else if ak.SecretSelector != nil { - // Fetch secrets matching labels, then filter by namespace - allSecrets := krt.Fetch(krtctx, secretCol, krt.FilterLabel(ak.SecretSelector.MatchLabels)) - for i := range allSecrets { - secret := &allSecrets[i] - if secret.Namespace == policy.Namespace { - secrets = append(secrets, secret) - } - } + // Fetch secrets matching labels and namespace + secrets = krt.Fetch(krtctx, secretCol, + krt.FilterLabel(ak.SecretSelector.MatchLabels), + krt.FilterGeneric(func(obj any) bool { + secret := obj.(ir.Secret) + return secret.Namespace == policy.Namespace + }), + ) if len(secrets) == 0 { return fmt.Errorf("no secrets found matching selector %v in namespace %s", ak.SecretSelector.MatchLabels, policy.Namespace) } diff --git a/internal/kgateway/translator/gateway/gateway_translator_test.go b/internal/kgateway/translator/gateway/gateway_translator_test.go index 9f8b995bde9..d1edfcbfd06 100644 --- a/internal/kgateway/translator/gateway/gateway_translator_test.go +++ b/internal/kgateway/translator/gateway/gateway_translator_test.go @@ -318,6 +318,28 @@ func TestBasic(t *testing.T) { }) }) + t.Run("TrafficPolicy API Key Authentication route override gateway", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-override.yaml", + outputFile: "traffic-policy/api-key-auth-override.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + + t.Run("TrafficPolicy API Key Authentication route override gateway with sectionName", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-override-section.yaml", + outputFile: "traffic-policy/api-key-auth-override-section.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + t.Run("TrafficPolicy with fail open rate limiting", func(t *testing.T) { test(t, translatorTestCase{ inputFile: "traffic-policy/fail-open", diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override-section.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override-section.yaml new file mode 100644 index 00000000000..2d164e1f4e0 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override-section.yaml @@ -0,0 +1,114 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /status + name: rule-status + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /get + name: rule-get +--- +# Gateway-level API key secret +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-gateway + namespace: default +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# Route-level API key secret (different from gateway-level) +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-route + namespace: default +type: Opaque +data: + client3: ay03ODk= + client4: ay05OTk= +--- +# TrafficPolicy with API key authentication at gateway level +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-gateway + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + apiKeyAuthentication: + keySources: + - header: "api-key" + forwardCredential: false + secretRef: + name: api-keys-gateway +--- +# TrafficPolicy with API key authentication at route level (overrides gateway-level) +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-route-override + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: example-route + sectionName: rule-get + apiKeyAuthentication: + keySources: + - header: "x-api-key" + forwardCredential: true + secretRef: + name: api-keys-route +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override.yaml new file mode 100644 index 00000000000..e832339544c --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override.yaml @@ -0,0 +1,111 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /status + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /get +--- +# Gateway-level API key secret +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-gateway + namespace: default +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# Route-level API key secret (different from gateway-level) +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-route + namespace: default +type: Opaque +data: + client3: ay03ODk= + client4: ay05OTk= +--- +# TrafficPolicy with API key authentication at gateway level +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-gateway + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + apiKeyAuthentication: + keySources: + - header: "api-key" + forwardCredential: false + secretRef: + name: api-keys-gateway +--- +# TrafficPolicy with API key authentication at route level (overrides gateway-level) +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-route-override + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: example-route + apiKeyAuthentication: + keySources: + - header: "x-api-key" + forwardCredential: true + secretRef: + name: api-keys-route +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml new file mode 100644 index 00000000000..3c16649a27a --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml @@ -0,0 +1,207 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-gateway + name: listener~80 +Routes: +- ignorePortInHostMatching: true + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-gateway + name: listener~80 + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + hideCredentials: true + keySources: + - header: api-key + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /status + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-rule-status-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + - match: + pathSeparatedPrefix: /get + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-route-override + name: listener~80~example_com-route-1-httproute-example-route-default-1-0-rule-get-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client4 + key: k-999 + - client: client3 + key: k-789 + forwarding: {} + keySources: + - header: x-api-key +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-gateway: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway + TrafficPolicy/default/api-key-auth-route-override: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml new file mode 100644 index 00000000000..b37c06fea9c --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml @@ -0,0 +1,223 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-gateway + name: listener~80 +Routes: +- ignorePortInHostMatching: true + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-gateway + name: listener~80 + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client2 + key: k-456 + - client: client1 + key: k-123 + forwarding: + hideCredentials: true + keySources: + - header: api-key + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /status + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-route-override + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client4 + key: k-999 + - client: client3 + key: k-789 + forwarding: {} + keySources: + - header: x-api-key + - match: + pathSeparatedPrefix: /get + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-route-override + name: listener~80~example_com-route-1-httproute-example-route-default-1-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client4 + key: k-999 + - client: client3 + key: k-789 + forwarding: {} + keySources: + - header: x-api-key +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-gateway: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway + TrafficPolicy/default/api-key-auth-route-override: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/test/e2e/features/apikeyauth/suite.go b/test/e2e/features/apikeyauth/suite.go index 54a14956bab..855094b6825 100644 --- a/test/e2e/features/apikeyauth/suite.go +++ b/test/e2e/features/apikeyauth/suite.go @@ -422,3 +422,106 @@ stringData: expectAPIKeyAuthDenied, ) } + +// TestAPIKeyAuthRouteOverrideGateway tests that route-level API key auth policy overrides gateway-level policy. +// Gateway-level policy uses one secret (k-123, k-456), while route-level policy uses a different secret (k-789, k-999). +// This verifies that the route-level policy takes precedence and uses its own secret. +func (s *testingSuite) TestAPIKeyAuthRouteOverrideGateway() { + // Verify HTTPRoute is accepted before running the test + s.TestInstallation.Assertions.EventuallyHTTPRouteCondition(s.Ctx, "httpbin-route-override", "default", gwv1.RouteConditionAccepted, metav1.ConditionTrue) + + // Test route with route-level policy override - should use route-level secret (k-789, k-999) + getReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/get"), + } + // missing API key, should fail + s.T().Log("The /get route has route-level API key auth policy, should fail when API key is missing") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key from route-level secret, should succeed + s.T().Log("The /get route should succeed with valid API key from route-level secret (k-789)") + getWithRouteAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "k-789")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithRouteAPIKeyCurlOpts, + expectStatus200Success, + ) + // has another valid API key from route-level secret, should succeed + s.T().Log("The /get route should succeed with another valid API key from route-level secret (k-999)") + getWithRouteAPIKey2CurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "k-999")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithRouteAPIKey2CurlOpts, + expectStatus200Success, + ) + // has API key from gateway-level secret, should fail (route-level policy overrides) + s.T().Log("The /get route should fail with API key from gateway-level secret (k-123) - route-level policy overrides") + getWithGatewayAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithGatewayAPIKeyCurlOpts, + expectAPIKeyAuthDenied, + ) + // has another API key from gateway-level secret, should fail + s.T().Log("The /get route should fail with another API key from gateway-level secret (k-456) - route-level policy overrides") + getWithGatewayAPIKey2CurlOpts := append(getReqCurlOpts, curl.WithHeader("api-key", "k-456")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithGatewayAPIKey2CurlOpts, + expectAPIKeyAuthDenied, + ) + + // Test route without route-level policy - should use gateway-level secret (k-123, k-456) + statusReqCurlOpts := []curl.Option{ + curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), + curl.WithHostHeader("httpbin"), + curl.WithPort(8080), + curl.WithPath("/status/200"), + } + // missing API key, should fail (gateway-level policy applies) + s.T().Log("The /status/200 route has no route-level policy, should require API key from gateway-level policy") + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusReqCurlOpts, + expectAPIKeyAuthDenied, + ) + // has valid API key from gateway-level secret, should succeed + s.T().Log("The /status/200 route should succeed with valid API key from gateway-level secret (k-123)") + statusWithGatewayAPIKeyCurlOpts := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithGatewayAPIKeyCurlOpts, + expectStatus200Success, + ) + // has another valid API key from gateway-level secret, should succeed + s.T().Log("The /status/200 route should succeed with another valid API key from gateway-level secret (k-456)") + statusWithGatewayAPIKey2CurlOpts := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-456")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithGatewayAPIKey2CurlOpts, + expectStatus200Success, + ) + // has API key from route-level secret, should fail (only applies to /get route) + s.T().Log("The /status/200 route should fail with API key from route-level secret (k-789) - only gateway-level policy applies") + statusWithRouteAPIKeyCurlOpts := append(statusReqCurlOpts, curl.WithHeader("api-key", "k-789")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithRouteAPIKeyCurlOpts, + expectAPIKeyAuthDenied, + ) +} diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-disable.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-disable.yaml new file mode 100644 index 00000000000..ef67dcef8a7 --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-disable.yaml @@ -0,0 +1,92 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httpbin-route-override + namespace: default +spec: + hostnames: + - httpbin + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + namespace: default + rules: + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /status/200 + - backendRefs: + - group: "" + kind: Service + name: httpbin + port: 8000 + weight: 1 + matches: + - path: + type: PathPrefix + value: /get + name: rule1 +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-secret + namespace: default +type: Opaque +stringData: + client1: k-123 + client2: k-456 +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-secret-route + namespace: default +type: Opaque +stringData: + client3: k-789 + client4: k-999 +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-gateway + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: gw + apiKeyAuthentication: + keySources: + - header: "api-key" + forwardCredential: false + secretRef: + name: api-keys-secret +--- +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-route-override + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httpbin-route-override + sectionName: rule1 + apiKeyAuthentication: + keySources: + - header: "api-key" + forwardCredential: false + secretRef: + name: api-keys-secret-route + diff --git a/test/e2e/features/apikeyauth/types.go b/test/e2e/features/apikeyauth/types.go index 73d71da34d0..8f04e7cd67c 100644 --- a/test/e2e/features/apikeyauth/types.go +++ b/test/e2e/features/apikeyauth/types.go @@ -23,6 +23,7 @@ var ( apiKeyAuthManifestQuery = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-query.yaml") apiKeyAuthManifestCookie = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-cookie.yaml") apiKeyAuthManifestSecretUpdate = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-secret-update.yaml") + apiKeyAuthManifestDisable = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-disable.yaml") // Core infrastructure objects that we need to track gatewayObjectMeta = metav1.ObjectMeta{ Name: "gw", @@ -63,5 +64,8 @@ var ( "TestAPIKeyAuthWithSecretUpdate": { Manifests: []string{apiKeyAuthManifestSecretUpdate}, }, + "TestAPIKeyAuthRouteOverrideGateway": { + Manifests: []string{apiKeyAuthManifestDisable}, + }, } ) From b4df3ea6eeb9647e332d22324227f5f9756349ce Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Wed, 3 Dec 2025 15:49:26 +0200 Subject: [PATCH 10/11] Support refgrants in SecretRef or Selector Signed-off-by: Yossi Mesika --- api/v1alpha1/traffic_policy_types.go | 3 +- api/v1alpha1/zz_generated.deepcopy.go | 4 +- .../gateway.kgateway.dev_trafficpolicies.yaml | 41 ++++- .../plugins/trafficpolicy/api_key_auth.go | 43 ++--- .../gateway/gateway_translator_test.go | 44 +++++ .../api-key-auth-secretref-with-refgrant.yaml | 98 +++++++++++ .../api-key-auth-secretref.yaml | 82 +++++++++ ...-key-auth-selector-no-matching-secret.yaml | 100 +++++++++++ .../api-key-auth-selector-with-refgrant.yaml | 113 ++++++++++++ .../api-key-auth-httproute.yaml | 8 +- .../api-key-auth-override-section.yaml | 4 +- .../traffic-policy/api-key-auth-override.yaml | 12 +- .../api-key-auth-secretref-with-refgrant.yaml | 162 ++++++++++++++++++ .../api-key-auth-secretref.yaml | 162 ++++++++++++++++++ ...-key-auth-selector-no-matching-secret.yaml | 144 ++++++++++++++++ .../api-key-auth-selector-with-refgrant.yaml | 162 ++++++++++++++++++ pkg/krtcollections/secrets.go | 61 ++++++- test/translator/test.go | 75 ++++++++ 18 files changed, 1270 insertions(+), 48 deletions(-) create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml create mode 100644 internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml diff --git a/api/v1alpha1/traffic_policy_types.go b/api/v1alpha1/traffic_policy_types.go index 530e7c1f16d..ca65ae19cba 100644 --- a/api/v1alpha1/traffic_policy_types.go +++ b/api/v1alpha1/traffic_policy_types.go @@ -1,7 +1,6 @@ package v1alpha1 import ( - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gwv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -457,7 +456,7 @@ type APIKeyAuthentication struct { // client2: "k-456" // // +optional - SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` + SecretRef *gwv1.SecretObjectReference `json:"secretRef,omitempty"` // secretSelector selects multiple secrets containing API Keys. If the same key is defined in multiple secrets, the // behavior is undefined. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c8b928c0a0f..172585cd455 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -115,8 +115,8 @@ func (in *APIKeyAuthentication) DeepCopyInto(out *APIKeyAuthentication) { } if in.SecretRef != nil { in, out := &in.SecretRef, &out.SecretRef - *out = new(corev1.LocalObjectReference) - **out = **in + *out = new(apisv1.SecretObjectReference) + (*in).DeepCopyInto(*out) } if in.SecretSelector != nil { in, out := &in.SecretSelector, &out.SecretSelector diff --git a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml index 9d6f16b790a..d786557ef6c 100644 --- a/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml +++ b/install/helm/kgateway-crds/templates/gateway.kgateway.dev_trafficpolicies.yaml @@ -145,17 +145,44 @@ spec: client1: "k-123" client2: "k-456" properties: - name: + group: default: "" description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + Group is the group of the referent. For example, "gateway.networking.k8s.io". + When unspecified or empty string, core API group is inferred. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: Secret + description: Kind is kind of the referent. For example "Secret". + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referenced object. When unspecified, the local + namespace is inferred. + + Note that when a namespace different than the local namespace is specified, + a ReferenceGrant object is required in the referent namespace to allow that + namespace's owner to accept the reference. See the ReferenceGrant + documentation for details. + + Support: Core + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - name type: object - x-kubernetes-map-type: atomic secretSelector: description: |- secretSelector selects multiple secrets containing API Keys. If the same key is defined in multiple secrets, the diff --git a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go index 113e4ba65dc..83581e3488d 100644 --- a/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go +++ b/internal/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -9,6 +9,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "github.com/kgateway-dev/kgateway/v2/api/v1alpha1" + "github.com/kgateway-dev/kgateway/v2/internal/kgateway/wellknown" + "github.com/kgateway-dev/kgateway/v2/pkg/krtcollections" "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk/collections" "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk/ir" ) @@ -64,40 +66,39 @@ func constructAPIKeyAuth( ak := spec.APIKeyAuthentication - // Resolve secrets using SecretIndex + // Resolve secrets using SecretIndex with ReferenceGrant validation var secrets []ir.Secret secretGK := schema.GroupKind{Group: "", Kind: "Secret"} - secretCol := commoncol.Secrets.GetSecretCollection(secretGK) - if secretCol == nil { - return fmt.Errorf("secret collection not found") + policyGK := wellknown.TrafficPolicyGVK.GroupKind() + from := krtcollections.From{ + GroupKind: policyGK, + Namespace: policy.Namespace, } if ak.SecretRef != nil { - // Use ResourceName format: Group/Kind/Namespace/Name - secretObjSource := ir.ObjectSource{ - Group: "", - Kind: "Secret", - Namespace: policy.Namespace, - Name: ak.SecretRef.Name, - } - secret := krt.FetchOne(krtctx, secretCol, krt.FilterKey(secretObjSource.ResourceName())) - if secret == nil { - return fmt.Errorf("API key secret %s not found in namespace %s", ak.SecretRef.Name, policy.Namespace) + secret, err := commoncol.Secrets.GetSecret(krtctx, from, *ak.SecretRef) + if err != nil { + return fmt.Errorf("API key secret %s: %w", ak.SecretRef.Name, err) } secrets = []ir.Secret{*secret} } else if ak.SecretSelector != nil { - // Fetch secrets matching labels and namespace - secrets = krt.Fetch(krtctx, secretCol, - krt.FilterLabel(ak.SecretSelector.MatchLabels), - krt.FilterGeneric(func(obj any) bool { - secret := obj.(ir.Secret) - return secret.Namespace == policy.Namespace - }), + // Fetch secrets matching labels and namespace with ReferenceGrant validation + var err error + secrets, err = commoncol.Secrets.GetSecretsBySelector( + krtctx, + from, + secretGK, + policy.Namespace, + ak.SecretSelector.MatchLabels, ) + if err != nil { + return fmt.Errorf("failed to get secrets by selector: %w", err) + } if len(secrets) == 0 { return fmt.Errorf("no secrets found matching selector %v in namespace %s", ak.SecretSelector.MatchLabels, policy.Namespace) } } else { + // We shouldn't get here because the spec validation should catch this return fmt.Errorf("either secretRef or secretSelector must be specified") } diff --git a/internal/kgateway/translator/gateway/gateway_translator_test.go b/internal/kgateway/translator/gateway/gateway_translator_test.go index 88b030c0afe..93a56b3aeb0 100644 --- a/internal/kgateway/translator/gateway/gateway_translator_test.go +++ b/internal/kgateway/translator/gateway/gateway_translator_test.go @@ -340,6 +340,50 @@ func TestBasic(t *testing.T) { }) }) + t.Run("TrafficPolicy API Key Authentication with SecretRef", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-secretref.yaml", + outputFile: "traffic-policy/api-key-auth-secretref.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + + t.Run("TrafficPolicy API Key Authentication with SecretRef and ReferenceGrant", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-secretref-with-refgrant.yaml", + outputFile: "traffic-policy/api-key-auth-secretref-with-refgrant.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + + t.Run("TrafficPolicy API Key Authentication with SecretSelector and ReferenceGrant", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-selector-with-refgrant.yaml", + outputFile: "traffic-policy/api-key-auth-selector-with-refgrant.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + + t.Run("TrafficPolicy API Key Authentication with SecretSelector no matching secrets", func(t *testing.T) { + test(t, translatorTestCase{ + inputFile: "traffic-policy/api-key-auth-selector-no-matching-secret.yaml", + outputFile: "traffic-policy/api-key-auth-selector-no-matching-secret.yaml", + gwNN: types.NamespacedName{ + Namespace: "default", + Name: "example-gateway", + }, + }) + }) + t.Run("TrafficPolicy with fail open rate limiting", func(t *testing.T) { test(t, translatorTestCase{ inputFile: "traffic-policy/fail-open", diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml new file mode 100644 index 00000000000..403eeabf698 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml @@ -0,0 +1,98 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /foo + filters: + - type: ExtensionRef + extensionRef: + group: gateway.kgateway.dev + kind: TrafficPolicy + name: api-key-auth-secretref +--- +# API key secret in other namespace +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: other +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# ReferenceGrant allowing access to the secret in other namespace +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-secret-access + namespace: other +spec: + from: + - group: gateway.kgateway.dev + kind: TrafficPolicy + namespace: default + to: + - group: "" + kind: Secret +--- +# TrafficPolicy with API key authentication using SecretRef with ReferenceGrant allowing access to the secret in other namespace +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-secretref + namespace: default +spec: + targetRefs: + - name: example-route + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - header: "x-api-key" + forwardCredential: false + clientIdHeader: "x-authenticated-client" + secretRef: + name: api-keys + namespace: other +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref.yaml new file mode 100644 index 00000000000..93ac5b257cd --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref.yaml @@ -0,0 +1,82 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /foo + filters: + - type: ExtensionRef + extensionRef: + group: gateway.kgateway.dev + kind: TrafficPolicy + name: api-key-auth-secretref +--- +# API key secret in same namespace +apiVersion: v1 +kind: Secret +metadata: + name: api-keys + namespace: default +type: Opaque +data: + client1: ay0xMjM= + client2: ay00NTY= +--- +# TrafficPolicy with API key authentication using SecretRef +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-secretref + namespace: default +spec: + targetRefs: + - name: example-route + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - header: "x-api-key" + forwardCredential: false + clientIdHeader: "x-authenticated-client" + secretRef: + name: api-keys +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml new file mode 100644 index 00000000000..621d345fb31 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml @@ -0,0 +1,100 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /foo + filters: + - type: ExtensionRef + extensionRef: + group: gateway.kgateway.dev + kind: TrafficPolicy + name: api-key-auth-selector +--- +# Secret with different labels that don't match the selector +apiVersion: v1 +kind: Secret +metadata: + name: same-ns-secret + namespace: default + labels: + app: other + type: different +type: Opaque +data: + client1: ay0xMjM= +--- +# Secret with matching labels but in other namespace (no reference grant) +apiVersion: v1 +kind: Secret +metadata: + name: other-ns-secret + namespace: other + labels: + app: api-keys + type: authentication +type: Opaque +data: + client1: ay0xMjM= +--- +# TrafficPolicy with API key authentication using SecretSelector with no matching secrets +# This should result in an error status because no secrets match the selector +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-selector + namespace: default +spec: + targetRefs: + - name: example-route + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - header: "x-api-key" + forwardCredential: false + clientIdHeader: "x-authenticated-client" + secretSelector: + matchLabels: + app: api-keys + type: authentication +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml new file mode 100644 index 00000000000..d0852c50ddd --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml @@ -0,0 +1,113 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: example-gateway + namespace: default +spec: + gatewayClassName: example-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "example.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: example-route + namespace: default +spec: + parentRefs: + - name: example-gateway + hostnames: + - "example.com" + rules: + - backendRefs: + - name: example-svc + port: 80 + matches: + - path: + type: PathPrefix + value: /foo + filters: + - type: ExtensionRef + extensionRef: + group: gateway.kgateway.dev + kind: TrafficPolicy + name: api-key-auth-selector +--- +# API key secrets with matching labels in same and other namespaces +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-1 + namespace: default + labels: + app: api-keys + type: authentication +type: Opaque +data: + client1: ay0xMjM= +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-keys-2 + namespace: other + labels: + app: api-keys + type: authentication +type: Opaque +data: + client2: ay00NTY= +--- +# ReferenceGrant allowing access to the secret in other namespace +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: allow-secret-access + namespace: other +spec: + from: + - group: gateway.kgateway.dev + kind: TrafficPolicy + namespace: default + to: + - group: "" + kind: Secret +--- +# TrafficPolicy with API key authentication using SecretSelector with ReferenceGrant allowing access to the secret in other namespace +apiVersion: gateway.kgateway.dev/v1alpha1 +kind: TrafficPolicy +metadata: + name: api-key-auth-selector + namespace: default +spec: + targetRefs: + - name: example-route + kind: HTTPRoute + group: gateway.networking.k8s.io + apiKeyAuthentication: + keySources: + - header: "x-api-key" + forwardCredential: false + clientIdHeader: "x-authenticated-client" + secretSelector: + matchLabels: + app: api-keys + type: authentication +--- +# Test service +apiVersion: v1 +kind: Service +metadata: + name: example-svc + namespace: default +spec: + selector: + app: example + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml index ff4dfc68f97..7bedb6406a8 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml @@ -64,10 +64,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client2 - key: k-456 - client: client1 key: k-123 + - client: client2 + key: k-456 forwarding: hideCredentials: true keySources: @@ -87,10 +87,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client2 - key: k-456 - client: client1 key: k-123 + - client: client2 + key: k-456 forwarding: hideCredentials: true keySources: diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml index 3c16649a27a..49b13438793 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml @@ -92,10 +92,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client4 - key: k-999 - client: client3 key: k-789 + - client: client4 + key: k-999 forwarding: {} keySources: - header: x-api-key diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml index b37c06fea9c..49b0d499d26 100644 --- a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml @@ -58,10 +58,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client2 - key: k-456 - client: client1 key: k-123 + - client: client2 + key: k-456 forwarding: hideCredentials: true keySources: @@ -86,10 +86,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client4 - key: k-999 - client: client3 key: k-789 + - client: client4 + key: k-999 forwarding: {} keySources: - header: x-api-key @@ -108,10 +108,10 @@ Routes: envoy.filters.http.api_key_auth: '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute credentials: - - client: client4 - key: k-999 - client: client3 key: k-789 + - client: client4 + key: k-999 forwarding: {} keySources: - header: x-api-key diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml new file mode 100644 index 00000000000..f119e67038d --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml @@ -0,0 +1,162 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + name: listener~80 +Routes: +- ignorePortInHostMatching: true + name: listener~80 + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /foo + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-secretref + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + header: x-authenticated-client + hideCredentials: true + keySources: + - header: x-api-key +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-secretref: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref.yaml new file mode 100644 index 00000000000..f119e67038d --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref.yaml @@ -0,0 +1,162 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + name: listener~80 +Routes: +- ignorePortInHostMatching: true + name: listener~80 + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /foo + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-secretref + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + header: x-authenticated-client + hideCredentials: true + keySources: + - header: x-api-key +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-secretref: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml new file mode 100644 index 00000000000..fa59c6a9200 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml @@ -0,0 +1,144 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + name: listener~80 +Routes: +- ignorePortInHostMatching: true + name: listener~80 + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - directResponse: + body: + inlineString: invalid route configuration detected and replaced with a direct + response. + status: 500 + match: + pathSeparatedPrefix: /foo + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: |- + Replaced Rule (0): failed to get secrets by selector: missing reference grant + failed to get secrets by selector: missing reference grant + reason: RouteRuleReplaced + status: "False" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-selector: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: 'failed to get secrets by selector: missing reference grant' + reason: Invalid + status: "False" + type: Accepted + - lastTransitionTime: null + message: "" + reason: Pending + status: "False" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml new file mode 100644 index 00000000000..ac4a0e95d86 --- /dev/null +++ b/internal/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml @@ -0,0 +1,162 @@ +Clusters: +- connectTimeout: 5s + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + ignoreHealthOnHostRemoval: true + metadata: {} + name: kube_default_example-svc_80 + type: EDS +- connectTimeout: 5s + metadata: {} + name: test-backend-plugin_default_example-svc_80 +Listeners: +- address: + socketAddress: + address: '::' + ipv4Compat: true + portValue: 80 + filterChains: + - filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + httpFilters: + - disabled: true + name: envoy.filters.http.api_key_auth + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: listener~80 + statPrefix: http + useRemoteAddress: true + name: listener~80 + name: listener~80 +Routes: +- ignorePortInHostMatching: true + name: listener~80 + virtualHosts: + - domains: + - example.com + name: listener~80~example_com + routes: + - match: + pathSeparatedPrefix: /foo + metadata: + filterMetadata: + merge.TrafficPolicy.gateway.kgateway.dev: + apiKeyAuth: + - gateway.kgateway.dev/TrafficPolicy/default/api-key-auth-selector + name: listener~80~example_com-route-0-httproute-example-route-default-0-0-matcher-0 + route: + cluster: kube_default_example-svc_80 + clusterNotFoundResponseCode: INTERNAL_SERVER_ERROR + typedPerFilterConfig: + envoy.filters.http.api_key_auth: + '@type': type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - client: client1 + key: k-123 + - client: client2 + key: k-456 + forwarding: + header: x-authenticated-client + hideCredentials: true + keySources: + - header: x-api-key +Statuses: + gateways: + default/example-gateway: + conditions: + - lastTransitionTime: null + message: "" + reason: ListenerSetsNotAllowed + status: Unknown + type: AttachedListenerSets + - lastTransitionTime: null + message: Successfully accepted Gateway + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully programmed Gateway + reason: Programmed + status: "True" + type: Programmed + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Successfully accepted Listener + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully verified that Listener has no conflicts + reason: NoConflicts + status: "False" + type: Conflicted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + - lastTransitionTime: null + message: Successfully programmed Listener + reason: Programmed + status: "True" + type: Programmed + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute + httpRoutes: + default/example-route: + parents: + - conditions: + - lastTransitionTime: null + message: Successfully accepted Route + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Successfully resolved all references + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: kgateway + parentRef: + group: "" + kind: "" + name: example-gateway + policies: + TrafficPolicy/default/api-key-auth-selector: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: example-gateway + namespace: default + conditions: + - lastTransitionTime: null + message: Policy accepted + reason: Valid + status: "True" + type: Accepted + - lastTransitionTime: null + message: Attached to all targets + reason: Attached + status: "True" + type: Attached + controllerName: kgateway.dev/kgateway diff --git a/pkg/krtcollections/secrets.go b/pkg/krtcollections/secrets.go index 3636d214010..968a65d2bcf 100644 --- a/pkg/krtcollections/secrets.go +++ b/pkg/krtcollections/secrets.go @@ -70,8 +70,61 @@ func (s *SecretIndex) GetSecret(kctx krt.HandlerContext, from From, secretRef gw return secret, nil } -// GetSecretCollection returns the secret collection for a given GroupKind. -// This is useful for accessing secrets without reference grant checks (e.g., same namespace). -func (s *SecretIndex) GetSecretCollection(gk schema.GroupKind) krt.Collection[ir.Secret] { - return s.secrets[gk] +// GetSecretsBySelector retrieves secrets matching the label selector in the specified namespace, +// validating reference grants to ensure the source object is allowed to reference each secret. +// Returns an error if any matching secret requires a ReferenceGrant but doesn't have one. +func (s *SecretIndex) GetSecretsBySelector( + kctx krt.HandlerContext, + from From, + secretGK schema.GroupKind, + namespace string, + matchLabels map[string]string, +) ([]ir.Secret, error) { + col := s.secrets[secretGK] + if col == nil { + return nil, ErrUnknownBackendKind + } + + // First, fetch all secrets matching the label selector + labelMatchedSecrets := krt.Fetch(kctx, col, + krt.FilterGeneric(func(obj any) bool { + secret := obj.(ir.Secret) + + // Check labels from the underlying Kubernetes Secret object + if secret.Obj == nil { + return false + } + objLabels := secret.Obj.GetLabels() + if objLabels == nil { + return false + } + // Check if all matchLabels are present and match + for key, value := range matchLabels { + if objLabels[key] != value { + return false + } + } + return true + }), + ) + + // Validate ReferenceGrant for cross-namespace secrets and collect allowed ones + var allowedSecrets []ir.Secret + for _, secret := range labelMatchedSecrets { + // Only check ReferenceGrant if this is a cross-namespace reference + if from.Namespace != secret.Namespace { + to := ir.ObjectSource{ + Group: secret.Group, + Kind: secret.Kind, + Namespace: secret.Namespace, + Name: secret.Name, + } + if !s.refgrants.ReferenceAllowed(kctx, from.GroupKind, from.Namespace, to) { + return nil, ErrMissingReferenceGrant + } + } + allowedSecrets = append(allowedSecrets, secret) + } + + return allowedSecrets, nil } diff --git a/test/translator/test.go b/test/translator/test.go index 507442d7752..55a787074d5 100644 --- a/test/translator/test.go +++ b/test/translator/test.go @@ -21,6 +21,7 @@ import ( "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/anypb" kubeclient "istio.io/istio/pkg/kube" "istio.io/istio/pkg/kube/krt" apiserverschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" @@ -40,6 +41,7 @@ import ( "github.com/kgateway-dev/kgateway/v2/internal/kgateway/translator" "github.com/kgateway-dev/kgateway/v2/internal/kgateway/translator/irtranslator" "github.com/kgateway-dev/kgateway/v2/internal/kgateway/translator/listener" + "github.com/kgateway-dev/kgateway/v2/internal/kgateway/utils" "github.com/kgateway-dev/kgateway/v2/internal/kgateway/wellknown" "github.com/kgateway-dev/kgateway/v2/pkg/apiclient" "github.com/kgateway-dev/kgateway/v2/pkg/apiclient/fake" @@ -345,9 +347,82 @@ func sortProxy(proxy *irtranslator.TranslationResult) *irtranslator.TranslationR return proxy.ExtraClusters[i].GetName() < proxy.ExtraClusters[j].GetName() }) + // Sort credentials in routes to ensure deterministic output + // This is to avoid local changes every time the test is run with REFRESH_GOLDEN=true + for _, routeConfig := range proxy.Routes { + sortCredentialsInRouteConfiguration(routeConfig) + } + return proxy } +// sortCredentialsInRouteConfiguration sorts API key auth credentials within route configurations +func sortCredentialsInRouteConfiguration(routeConfig *envoyroutev3.RouteConfiguration) { + if routeConfig == nil { + return + } + + for _, vh := range routeConfig.GetVirtualHosts() { + // Sort credentials in route-level typedPerFilterConfig + for _, route := range vh.GetRoutes() { + sortCredentialsInRoute(route) + } + + // Sort credentials in virtual host-level typedPerFilterConfig + if vh.GetTypedPerFilterConfig() != nil { + if config, ok := vh.GetTypedPerFilterConfig()["envoy.filters.http.api_key_auth"]; ok { + sortCredentialsInAny(config) + } + } + } + + // Sort credentials in route configuration-level typedPerFilterConfig + if routeConfig.GetTypedPerFilterConfig() != nil { + if config, ok := routeConfig.GetTypedPerFilterConfig()["envoy.filters.http.api_key_auth"]; ok { + sortCredentialsInAny(config) + } + } +} + +// sortCredentialsInRoute sorts API key auth credentials in a route's typedPerFilterConfig +func sortCredentialsInRoute(route *envoyroutev3.Route) { + if route == nil || route.GetTypedPerFilterConfig() == nil { + return + } + + if config, ok := route.GetTypedPerFilterConfig()["envoy.filters.http.api_key_auth"]; ok { + sortCredentialsInAny(config) + } +} + +// sortCredentialsInAny sorts credentials in an ApiKeyAuthPerRoute config stored as anypb.Any +func sortCredentialsInAny(config *anypb.Any) { + if config == nil { + return + } + + // Unmarshal to ApiKeyAuthPerRoute + apiKeyAuth := &envoyapikeyauthv3.ApiKeyAuthPerRoute{} + if err := config.UnmarshalTo(apiKeyAuth); err != nil { + // Not an ApiKeyAuthPerRoute, skip + return + } + + // Sort credentials by client name + if len(apiKeyAuth.Credentials) > 0 { + sort.Slice(apiKeyAuth.Credentials, func(i, j int) bool { + return apiKeyAuth.Credentials[i].Client < apiKeyAuth.Credentials[j].Client + }) + + // Marshal back to Any and update the config + a, err := utils.MessageToAny(apiKeyAuth) + if err == nil { + config.TypeUrl = a.TypeUrl + config.Value = a.Value + } + } +} + func compareClusters(expectedFile string, actualClusters []*envoyclusterv3.Cluster) (string, error) { expectedOutput := &translationResult{} if err := ReadYamlFile(expectedFile, expectedOutput); err != nil { From 181b515f7ae4f16698f5aac04eac6b3e662b2c6e Mon Sep 17 00:00:00 2001 From: Yossi Mesika Date: Sun, 7 Dec 2025 11:01:13 +0200 Subject: [PATCH 11/11] Review comments addressed Signed-off-by: Yossi Mesika --- .../plugins/trafficpolicy/api_key_auth.go | 1 - pkg/krtcollections/secrets.go | 20 ++++++++--- test/e2e/features/apikeyauth/suite.go | 36 +++++++++++++++++++ ...isable.yaml => api-key-auth-override.yaml} | 0 .../apikeyauth/testdata/api-key-auth.yaml | 1 + test/e2e/features/apikeyauth/types.go | 4 +-- 6 files changed, 54 insertions(+), 8 deletions(-) rename test/e2e/features/apikeyauth/testdata/{api-key-auth-disable.yaml => api-key-auth-override.yaml} (100%) diff --git a/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go index 00a16bc6695..3698bc32ada 100644 --- a/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go +++ b/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -88,7 +88,6 @@ func constructAPIKeyAuth( krtctx, from, secretGK, - policy.Namespace, ak.SecretSelector.MatchLabels, ) if err != nil { diff --git a/pkg/krtcollections/secrets.go b/pkg/krtcollections/secrets.go index 968a65d2bcf..fc74b819261 100644 --- a/pkg/krtcollections/secrets.go +++ b/pkg/krtcollections/secrets.go @@ -70,14 +70,15 @@ func (s *SecretIndex) GetSecret(kctx krt.HandlerContext, from From, secretRef gw return secret, nil } -// GetSecretsBySelector retrieves secrets matching the label selector in the specified namespace, -// validating reference grants to ensure the source object is allowed to reference each secret. -// Returns an error if any matching secret requires a ReferenceGrant but doesn't have one. +// GetSecretsBySelector retrieves secrets matching the label selector, +// validating reference grants to ensure the source (from) object is allowed to reference each secret. +// Processes all matching secrets, skipping those without required ReferenceGrants. +// Returns all accessible secrets. Only returns an error if no accessible secrets were found and some matching secrets +// were skipped due to missing ReferenceGrants (indicating a possible configuration issue). func (s *SecretIndex) GetSecretsBySelector( kctx krt.HandlerContext, from From, secretGK schema.GroupKind, - namespace string, matchLabels map[string]string, ) ([]ir.Secret, error) { col := s.secrets[secretGK] @@ -110,6 +111,7 @@ func (s *SecretIndex) GetSecretsBySelector( // Validate ReferenceGrant for cross-namespace secrets and collect allowed ones var allowedSecrets []ir.Secret + var hasMissingGrants bool for _, secret := range labelMatchedSecrets { // Only check ReferenceGrant if this is a cross-namespace reference if from.Namespace != secret.Namespace { @@ -120,11 +122,19 @@ func (s *SecretIndex) GetSecretsBySelector( Name: secret.Name, } if !s.refgrants.ReferenceAllowed(kctx, from.GroupKind, from.Namespace, to) { - return nil, ErrMissingReferenceGrant + hasMissingGrants = true + continue } } allowedSecrets = append(allowedSecrets, secret) } + // Only return an error if no allowed secrets were found and there were missing grants. + // We don't want to list all the secrets that were skipped. We only want to hint + // the user that it might be a configuration issue. + if len(allowedSecrets) == 0 && hasMissingGrants { + return allowedSecrets, ErrMissingReferenceGrant + } + return allowedSecrets, nil } diff --git a/test/e2e/features/apikeyauth/suite.go b/test/e2e/features/apikeyauth/suite.go index 855094b6825..d3dd3b56d17 100644 --- a/test/e2e/features/apikeyauth/suite.go +++ b/test/e2e/features/apikeyauth/suite.go @@ -57,6 +57,15 @@ func (s *testingSuite) TestAPIKeyAuthWithHTTPRouteLevelPolicy() { statusWithAPIKeyCurlOpts, expectStatus200Success, ) + // has valid API key with Bearer prefix in Authorization header, should succeed + s.T().Log("The /status route has API key auth applied at HTTPRoute level, should succeed when valid API key is present with Bearer prefix in Authorization header") + statusWithBearerAPIKeyCurlOpts := append(statusReqCurlOpts, curl.WithHeader("Authorization", "Bearer k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + statusWithBearerAPIKeyCurlOpts, + expectStatus200Success, + ) getReqCurlOpts := []curl.Option{ curl.WithHost(kubeutils.ServiceFQDN(gatewayService.ObjectMeta)), @@ -81,6 +90,33 @@ func (s *testingSuite) TestAPIKeyAuthWithHTTPRouteLevelPolicy() { getWithAPIKeyCurlOpts, expectStatus200Success, ) + // has valid API key with Bearer prefix in Authorization header, should succeed + s.T().Log("The /get route has API key auth applied at HTTPRoute level, should succeed when valid API key is present with Bearer prefix in Authorization header") + getWithBearerAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("Authorization", "Bearer k-123")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithBearerAPIKeyCurlOpts, + expectStatus200Success, + ) + // has valid API key with Bearer prefix using different key, should succeed + s.T().Log("The /get route has API key auth applied at HTTPRoute level, should succeed when valid API key (k-456) is present with Bearer prefix in Authorization header") + getWithBearerAPIKey2CurlOpts := append(getReqCurlOpts, curl.WithHeader("Authorization", "Bearer k-456")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithBearerAPIKey2CurlOpts, + expectStatus200Success, + ) + // has invalid API key with Bearer prefix in Authorization header, should fail + s.T().Log("The /get route has API key auth applied at HTTPRoute level, should fail when invalid API key is present with Bearer prefix in Authorization header") + getWithInvalidBearerAPIKeyCurlOpts := append(getReqCurlOpts, curl.WithHeader("Authorization", "Bearer invalid-key")) + s.TestInstallation.Assertions.AssertEventualCurlResponse( + s.Ctx, + testdefaults.CurlPodExecOpt, + getWithInvalidBearerAPIKeyCurlOpts, + expectAPIKeyAuthDenied, + ) } // TestAPIKeyAuthWithRouteLevelPolicy tests API key authentication with TrafficPolicy applied at route level (sectionName) diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-disable.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-override.yaml similarity index 100% rename from test/e2e/features/apikeyauth/testdata/api-key-auth-disable.yaml rename to test/e2e/features/apikeyauth/testdata/api-key-auth-override.yaml diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml index 6b86add3fa4..64b05fa5f4f 100644 --- a/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth.yaml @@ -56,6 +56,7 @@ spec: apiKeyAuthentication: keySources: - header: "api-key" + - header: "Authorization" forwardCredential: true clientIdHeader: "x-client-id" secretRef: diff --git a/test/e2e/features/apikeyauth/types.go b/test/e2e/features/apikeyauth/types.go index 8f04e7cd67c..a4b995c24b9 100644 --- a/test/e2e/features/apikeyauth/types.go +++ b/test/e2e/features/apikeyauth/types.go @@ -23,7 +23,7 @@ var ( apiKeyAuthManifestQuery = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-query.yaml") apiKeyAuthManifestCookie = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-cookie.yaml") apiKeyAuthManifestSecretUpdate = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-secret-update.yaml") - apiKeyAuthManifestDisable = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-disable.yaml") + apiKeyAuthManifestOverride = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-override.yaml") // Core infrastructure objects that we need to track gatewayObjectMeta = metav1.ObjectMeta{ Name: "gw", @@ -65,7 +65,7 @@ var ( Manifests: []string{apiKeyAuthManifestSecretUpdate}, }, "TestAPIKeyAuthRouteOverrideGateway": { - Manifests: []string{apiKeyAuthManifestDisable}, + Manifests: []string{apiKeyAuthManifestOverride}, }, } )