diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 591f85bb6ff..c1491899049 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$$|^TestKgateway$$/^JWT$$|^TestKgateway$$/^BasicAuth$$' + go-test-run-regex: '^TestKgateway$$/^TimeoutRetry$$|^TestKgateway$$/^HeaderModifiers$$|^TestKgateway$$/^RBAC$$|^TestKgateway$$/^APIKeyAuth$$|^TestKgateway$$/^Deployer$$|^TestKgateway$$/^Transforms$$|^TestRouteReplacement$$|^TestKgateway$$/^RouteDelegation$$|^TestKgateway$$/^JWT$$|^TestKgateway$$/^BasicAuth$$' localstack: 'false' # August 29, 2025: ~9 minutes - cluster-name: 'cluster-six' diff --git a/api/v1alpha1/kgateway/traffic_policy_types.go b/api/v1alpha1/kgateway/traffic_policy_types.go index 5bb9d237109..1940a438c97 100644 --- a/api/v1alpha1/kgateway/traffic_policy_types.go +++ b/api/v1alpha1/kgateway/traffic_policy_types.go @@ -139,6 +139,10 @@ type TrafficPolicySpec struct { // This controls authentication using username/password credentials in the Authorization header. // +optional BasicAuth *BasicAuthPolicy `json:"basicAuth,omitempty"` + + // APIKeyAuthentication authenticates users based on a configured API Key. + // +optional + APIKeyAuthentication *APIKeyAuthentication `json:"apiKeyAuthentication,omitempty"` } // URLRewrite specifies URL rewrite rules using regular expressions. @@ -416,6 +420,118 @@ type CSRFPolicy struct { AdditionalOrigins []shared.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"` + + // 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). + // +optional + 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. + // Example: "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. + // + // 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 *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. + // + // 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 *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"` +} + // +kubebuilder:validation:ExactlyOneOf=maxRequestSize;disable type Buffer struct { // MaxRequestSize sets the maximum size in bytes of a message body to buffer. diff --git a/api/v1alpha1/kgateway/zz_generated.deepcopy.go b/api/v1alpha1/kgateway/zz_generated.deepcopy.go index b53e3847635..7758550149f 100644 --- a/api/v1alpha1/kgateway/zz_generated.deepcopy.go +++ b/api/v1alpha1/kgateway/zz_generated.deepcopy.go @@ -13,6 +13,78 @@ import ( apisv1 "sigs.k8s.io/gateway-api/apis/v1" ) +// 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.ForwardCredential != nil { + in, out := &in.ForwardCredential, &out.ForwardCredential + *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(apisv1.SecretObjectReference) + (*in).DeepCopyInto(*out) + } + if in.SecretSelector != nil { + in, out := &in.SecretSelector, &out.SecretSelector + *out = new(LabelSelector) + (*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 *AccessLog) DeepCopyInto(out *AccessLog) { *out = *in @@ -2701,6 +2773,28 @@ func (in *KubernetesProxyConfig) DeepCopy() *KubernetesProxyConfig { 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 *ListenerConfig) DeepCopyInto(out *ListenerConfig) { *out = *in @@ -4491,6 +4585,11 @@ func (in *TrafficPolicySpec) DeepCopyInto(out *TrafficPolicySpec) { *out = new(BasicAuthPolicy) (*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 4e33e8941c4..e97a6da4da8 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,167 @@ spec: description: TrafficPolicySpec defines the desired state of a traffic policy. properties: + apiKeyAuthentication: + description: APIKeyAuthentication authenticates users based on a configured + API Key. + properties: + clientIdHeader: + description: |- + clientIdHeader specifies the header name to forward the authenticated client identifier. + If not specified, the client identifier will not be forwarded in any header. + Example: "x-client-id" + type: string + forwardCredential: + 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. + 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: + 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: + group: + default: "" + description: |- + 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 + 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/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go b/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go new file mode 100644 index 00000000000..3698bc32ada --- /dev/null +++ b/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth.go @@ -0,0 +1,219 @@ +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/kgateway" + "github.com/kgateway-dev/kgateway/v2/pkg/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" +) + +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.ApiKeyAuthPerRoute +} + +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 per-route configuration +func constructAPIKeyAuth( + krtctx krt.HandlerContext, + policy *kgateway.TrafficPolicy, + commoncol *collections.CommonCollections, + out *trafficPolicySpecIr, +) error { + spec := policy.Spec + if spec.APIKeyAuthentication == nil { + return nil + } + + ak := spec.APIKeyAuthentication + + // Resolve secrets using SecretIndex with ReferenceGrant validation + var secrets []ir.Secret + secretGK := schema.GroupKind{Group: "", Kind: "Secret"} + policyGK := wellknown.TrafficPolicyGVK.GroupKind() + from := krtcollections.From{ + GroupKind: policyGK, + Namespace: policy.Namespace, + } + + if ak.SecretRef != nil { + 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 with ReferenceGrant validation + var err error + secrets, err = commoncol.Secrets.GetSecretsBySelector( + krtctx, + from, + secretGK, + 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") + } + + // 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 true since ForwardCredential defaults to false) + hideCredentials := true + if ak.ForwardCredential != nil { + hideCredentials = !(*ak.ForwardCredential) + } + + // Build Envoy API key auth per-route configuration + apiKeyAuthPolicy := &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + Credentials: credentials, + KeySources: envoyKeySources, + Forwarding: &envoyapikeyauthv3.Forwarding{ + 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, + } + + return nil +} + +// handleAPIKeyAuth configures the API key auth filter and per-route API key auth configuration. +// 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, + apiKeyAuthIr *apiKeyAuthIR, +) { + if apiKeyAuthIr == nil || apiKeyAuthIr.config == nil { + return + } + + // 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{} + } +} diff --git a/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go b/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go new file mode 100644 index 00000000000..aab9a12246f --- /dev/null +++ b/pkg/kgateway/extensions2/plugins/trafficpolicy/api_key_auth_test.go @@ -0,0 +1,315 @@ +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" + + "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.ApiKeyAuthPerRoute { + return &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + 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 policy 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.ApiKeyAuthPerRoute{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key1", Client: "client1"}, + }, + }, + }, + apiKeyAuth2: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key2", Client: "client2"}, + }, + }, + }, + expected: false, + }, + { + name: "same credentials are equal", + apiKeyAuth1: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + Credentials: []*envoyapikeyauthv3.Credential{ + {Key: "key1", Client: "client1"}, + }, + }, + }, + apiKeyAuth2: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + 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 policy validates successfully", + apiKeyAuth: &apiKeyAuthIR{config: nil}, + wantErr: false, + }, + { + name: "valid policy 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{ + Header: "x-client-id", + HideCredentials: false, + }, + }, + }, + wantErr: false, + }, + { + name: "policy with empty credentials validates successfully", + apiKeyAuth: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + Credentials: []*envoyapikeyauthv3.Credential{}, + KeySources: []*envoyapikeyauthv3.KeySource{ + { + Header: "api-key", + }, + }, + }, + }, + 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 { + 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 policy does nothing", + apiKeyAuthIr: &apiKeyAuthIR{config: nil}, + expectChain: false, + expectRoute: false, + }, + { + name: "valid policy adds to chain and route", + apiKeyAuthIr: &apiKeyAuthIR{ + config: &envoyapikeyauthv3.ApiKeyAuthPerRoute{ + 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") + } + }) + } +} diff --git a/pkg/kgateway/extensions2/plugins/trafficpolicy/constructor.go b/pkg/kgateway/extensions2/plugins/trafficpolicy/constructor.go index 9b77169952e..b0fd8c9e715 100644 --- a/pkg/kgateway/extensions2/plugins/trafficpolicy/constructor.go +++ b/pkg/kgateway/extensions2/plugins/trafficpolicy/constructor.go @@ -99,6 +99,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/pkg/kgateway/extensions2/plugins/trafficpolicy/merge.go b/pkg/kgateway/extensions2/plugins/trafficpolicy/merge.go index b9e3ec912d2..89526f55a88 100644 --- a/pkg/kgateway/extensions2/plugins/trafficpolicy/merge.go +++ b/pkg/kgateway/extensions2/plugins/trafficpolicy/merge.go @@ -55,6 +55,7 @@ func MergeTrafficPolicies( mergeCompression, mergeBasicAuth, mergeURLRewrite, + mergeAPIKeyAuth, } for _, mergeFunc := range mergeFuncs { @@ -467,6 +468,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/pkg/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go b/pkg/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go index 6ed5127d4d6..2375563695d 100644 --- a/pkg/kgateway/extensions2/plugins/trafficpolicy/traffic_policy_plugin.go +++ b/pkg/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" + envoy_api_key_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/api_key_auth/v3" envoy_basic_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3" bufferv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/buffer/v3" compressorv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/compressor/v3" @@ -102,6 +103,7 @@ type trafficPolicySpecIr struct { decompression *decompressionIR basicAuth *basicAuthIR urlRewrite *urlRewriteIR + apiKeyAuth *apiKeyAuthIR } func (d *TrafficPolicy) CreationTime() time.Time { @@ -174,6 +176,9 @@ func (d *TrafficPolicy) Equals(in any) bool { if !d.spec.urlRewrite.Equals(d2.spec.urlRewrite) { return false } + if !d.spec.apiKeyAuth.Equals(d2.spec.apiKeyAuth) { + return false + } return true } @@ -199,6 +204,7 @@ func (p *TrafficPolicy) Validate() error { validators = append(validators, p.spec.decompression.Validate) validators = append(validators, p.spec.basicAuth.Validate) validators = append(validators, p.spec.urlRewrite.Validate) + validators = append(validators, p.spec.apiKeyAuth.Validate) for _, validator := range validators { if err := validator(); err != nil { return err @@ -226,6 +232,7 @@ type trafficPolicyPluginGwPass struct { compressorInChain map[string]*compressorv3.Compressor decompressorInChain map[string]*decompressorv3.Decompressor basicAuthInChain map[string]*envoy_basic_auth_v3.BasicAuth + apiKeyAuthInChain map[string]*envoy_api_key_auth_v3.ApiKeyAuth } var _ ir.ProxyTranslationPass = &trafficPolicyPluginGwPass{} @@ -570,6 +577,13 @@ func (p *trafficPolicyPluginGwPass) HttpFilters(_ ir.HttpFiltersContext, fcc ir. 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 } @@ -606,6 +620,7 @@ func (p *trafficPolicyPluginGwPass) handlePolicies( p.handleCompression(fcn, typedFilterConfig, spec.compression) p.handleDecompression(fcn, typedFilterConfig, spec.decompression) p.handleBasicAuth(fcn, typedFilterConfig, spec.basicAuth) + p.handleAPIKeyAuth(fcn, typedFilterConfig, spec.apiKeyAuth) } // handlePerRoutePolicies handles policies that are meant to be processed at the route level diff --git a/pkg/kgateway/translator/gateway/gateway_translator_test.go b/pkg/kgateway/translator/gateway/gateway_translator_test.go index 722beb68d9b..ad2552e4802 100644 --- a/pkg/kgateway/translator/gateway/gateway_translator_test.go +++ b/pkg/kgateway/translator/gateway/gateway_translator_test.go @@ -308,6 +308,105 @@ 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 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 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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-gateway.yaml new file mode 100644 index 00000000000..4ed4c9e9853 --- /dev/null +++ b/pkg/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" + forwardCredential: 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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml new file mode 100644 index 00000000000..18f46057115 --- /dev/null +++ b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-httproute.yaml @@ -0,0 +1,81 @@ +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" + 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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override-section.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override-section.yaml new file mode 100644 index 00000000000..2d164e1f4e0 --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-override.yaml new file mode 100644 index 00000000000..e832339544c --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml new file mode 100644 index 00000000000..b64d0b6c500 --- /dev/null +++ b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-route.yaml @@ -0,0 +1,85 @@ +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" + 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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml new file mode 100644 index 00000000000..403eeabf698 --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-secretref.yaml new file mode 100644 index 00000000000..93ac5b257cd --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml b/pkg/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/pkg/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/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml b/pkg/kgateway/translator/gateway/testutils/inputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml new file mode 100644 index 00000000000..d0852c50ddd --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.yaml new file mode 100644 index 00000000000..375fa5e2e2c --- /dev/null +++ b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-gateway.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 + 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: {} + 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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml new file mode 100644 index 00000000000..7bedb6406a8 --- /dev/null +++ b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-httproute.yaml @@ -0,0 +1,184 @@ +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: + 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: + 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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override-section.yaml new file mode 100644 index 00000000000..49b13438793 --- /dev/null +++ b/pkg/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: client3 + key: k-789 + - client: client4 + key: k-999 + 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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-override.yaml new file mode 100644 index 00000000000..49b0d499d26 --- /dev/null +++ b/pkg/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: 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 + 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: client3 + key: k-789 + - client: client4 + key: k-999 + 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: client3 + key: k-789 + - client: client4 + key: k-999 + 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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-route.yaml new file mode 100644 index 00000000000..86bffd12ae9 --- /dev/null +++ b/pkg/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-authenticated-client + 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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref-with-refgrant.yaml new file mode 100644 index 00000000000..f119e67038d --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-secretref.yaml new file mode 100644 index 00000000000..f119e67038d --- /dev/null +++ b/pkg/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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-no-matching-secret.yaml b/pkg/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/pkg/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/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml b/pkg/kgateway/translator/gateway/testutils/outputs/traffic-policy/api-key-auth-selector-with-refgrant.yaml new file mode 100644 index 00000000000..ac4a0e95d86 --- /dev/null +++ b/pkg/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 1aef63cab84..fc74b819261 100644 --- a/pkg/krtcollections/secrets.go +++ b/pkg/krtcollections/secrets.go @@ -69,3 +69,72 @@ func (s *SecretIndex) GetSecret(kctx krt.HandlerContext, from From, secretRef gw } return secret, nil } + +// 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, + 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 + var hasMissingGrants bool + 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) { + 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 new file mode 100644 index 00000000000..d3dd3b56d17 --- /dev/null +++ b/test/e2e/features/apikeyauth/suite.go @@ -0,0 +1,563 @@ +//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, + ) + // 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)), + 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, + ) + // 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) +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) works") + 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, + ) +} + +// 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-cookie.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-cookie.yaml new file mode 100644 index 00000000000..9f2911bd875 --- /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 + forwardCredential: true + secretRef: + name: api-keys-cookie + diff --git a/test/e2e/features/apikeyauth/testdata/api-key-auth-override.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-override.yaml new file mode 100644 index 00000000000..ef67dcef8a7 --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth-override.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/testdata/api-key-auth-query.yaml b/test/e2e/features/apikeyauth/testdata/api-key-auth-query.yaml new file mode 100644 index 00000000000..cb8395405e1 --- /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 + 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 new file mode 100644 index 00000000000..dfa4935dbf7 --- /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" + 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 new file mode 100644 index 00000000000..c58375d68b9 --- /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" + 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 new file mode 100644 index 00000000000..64b05fa5f4f --- /dev/null +++ b/test/e2e/features/apikeyauth/testdata/api-key-auth.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 + 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" + - header: "Authorization" + forwardCredential: true + clientIdHeader: "x-client-id" + 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..a4b995c24b9 --- /dev/null +++ b/test/e2e/features/apikeyauth/types.go @@ -0,0 +1,71 @@ +//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") + apiKeyAuthManifestOverride = filepath.Join(fsutils.MustGetThisDir(), "testdata", "api-key-auth-override.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}, + }, + "TestAPIKeyAuthRouteOverrideGateway": { + Manifests: []string{apiKeyAuthManifestOverride}, + }, + } +) diff --git a/test/e2e/tests/kgateway_tests.go b/test/e2e/tests/kgateway_tests.go index 0f88fca79f6..949eeb58f17 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" @@ -84,6 +85,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) kubeGatewaySuiteRunner.Register("FrontendTLS", frontendtls.NewTestingSuite) diff --git a/test/translator/test.go b/test/translator/test.go index a24bf1fb36e..b8ea8cec108 100644 --- a/test/translator/test.go +++ b/test/translator/test.go @@ -15,12 +15,14 @@ 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" "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" @@ -42,6 +44,7 @@ import ( "github.com/kgateway-dev/kgateway/v2/pkg/kgateway/translator" "github.com/kgateway-dev/kgateway/v2/pkg/kgateway/translator/irtranslator" "github.com/kgateway-dev/kgateway/v2/pkg/kgateway/translator/listener" + "github.com/kgateway-dev/kgateway/v2/pkg/kgateway/utils" "github.com/kgateway-dev/kgateway/v2/pkg/kgateway/wellknown" "github.com/kgateway-dev/kgateway/v2/pkg/krtcollections" "github.com/kgateway-dev/kgateway/v2/pkg/pluginsdk" @@ -322,7 +325,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 { @@ -340,9 +348,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 {