diff --git a/api/v1alpha1/basic_auth_types.go b/api/v1alpha1/basic_auth_types.go index b0a446e049b..97fa66d5e76 100644 --- a/api/v1alpha1/basic_auth_types.go +++ b/api/v1alpha1/basic_auth_types.go @@ -7,6 +7,8 @@ package v1alpha1 import gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1" +const BasicAuthUsersSecretKey = ".htpasswd" + // BasicAuth defines the configuration for the HTTP Basic Authentication. type BasicAuth struct { // The Kubernetes secret which contains the username-password pairs in diff --git a/go.mod b/go.mod index 839ae412cbc..3b793c2c05a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.21 require ( github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 github.com/davecgh/go-spew v1.1.1 - github.com/envoyproxy/go-control-plane v0.11.2-0.20231020171731-dd48dc81e5ce + github.com/envoyproxy/go-control-plane v0.11.2-0.20231116045842-b54b6db2c2a8 github.com/envoyproxy/ratelimit v1.4.1-0.20230427142404-e2a87f41d3a7 github.com/evanphx/json-patch/v5 v5.7.0 github.com/go-logfmt/logfmt v0.6.0 diff --git a/go.sum b/go.sum index d720aeeb5fc..5c1f6389f34 100644 --- a/go.sum +++ b/go.sum @@ -93,8 +93,8 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.11.2-0.20231020171731-dd48dc81e5ce h1:Nk6X9GLZoScphDZOVWJ8zodLPrv0aXmscdIMGZQDR3c= -github.com/envoyproxy/go-control-plane v0.11.2-0.20231020171731-dd48dc81e5ce/go.mod h1:3X10o7QcAVxP4y/hnTLgkXLwuZV2DxAEh6uaYD5PoxI= +github.com/envoyproxy/go-control-plane v0.11.2-0.20231116045842-b54b6db2c2a8 h1:HvQxuGnVQ7zCIz2B90WuTfOcJhoLW1d3u1NQ8j0T9BU= +github.com/envoyproxy/go-control-plane v0.11.2-0.20231116045842-b54b6db2c2a8/go.mod h1:3X10o7QcAVxP4y/hnTLgkXLwuZV2DxAEh6uaYD5PoxI= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index 6adcd8d1398..70dad753ef4 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -263,6 +263,7 @@ func (t *Translator) translateSecurityPolicyForRoute( cors *ir.CORS jwt *ir.JWT oidc *ir.OIDC + basicAuth *ir.BasicAuth err, errs error ) @@ -282,6 +283,12 @@ func (t *Translator) translateSecurityPolicyForRoute( } } + if policy.Spec.BasicAuth != nil { + if basicAuth, err = t.buildBasicAuth(policy, resources); err != nil { + errs = multierror.Append(errs, err) + } + } + // Apply IR to all relevant routes // Note: there are multiple features in a security policy, even if some of them // are invalid, we still want to apply the valid ones. @@ -296,6 +303,7 @@ func (t *Translator) translateSecurityPolicyForRoute( r.CORS = cors r.JWT = jwt r.OIDC = oidc + r.BasicAuth = basicAuth } } } @@ -311,6 +319,7 @@ func (t *Translator) translateSecurityPolicyForGateway( cors *ir.CORS jwt *ir.JWT oidc *ir.OIDC + basicAuth *ir.BasicAuth err, errs error ) @@ -326,8 +335,13 @@ func (t *Translator) translateSecurityPolicyForGateway( } if policy.Spec.OIDC != nil { - oidc, err = t.buildOIDC(policy, resources) - if err != nil { + if oidc, err = t.buildOIDC(policy, resources); err != nil { + errs = multierror.Append(errs, err) + } + } + + if policy.Spec.BasicAuth != nil { + if basicAuth, err = t.buildBasicAuth(policy, resources); err != nil { errs = multierror.Append(errs, err) } } @@ -336,9 +350,6 @@ func (t *Translator) translateSecurityPolicyForGateway( // If the feature is already set, then skip it, since it must have be // set by a policy attaching to the route // - // It can be difficult to reason about the state of the system if we apply - // part of the policy and not the rest. Therefore, we either apply all of it - // or none of it (when get errors when translating the policy) // Note: there are multiple features in a security policy, even if some of them // are invalid, we still want to apply the valid ones. irKey := t.getIRKey(gateway.Gateway) @@ -357,6 +368,9 @@ func (t *Translator) translateSecurityPolicyForGateway( if r.OIDC == nil { r.OIDC = oidc } + if r.BasicAuth == nil { + r.BasicAuth = basicAuth + } } } return errs @@ -546,3 +560,36 @@ func validateTokenEndpoint(tokenEndpoint string) error { } return nil } + +func (t *Translator) buildBasicAuth( + policy *egv1a1.SecurityPolicy, + resources *Resources) (*ir.BasicAuth, error) { + var ( + basicAuth = policy.Spec.BasicAuth + usersSecret *v1.Secret + err error + ) + + from := crossNamespaceFrom{ + group: egv1a1.GroupName, + kind: KindSecurityPolicy, + namespace: policy.Namespace, + } + if usersSecret, err = t.validateSecretRef( + false, from, basicAuth.Users, resources); err != nil { + return nil, err + } + + usersSecretBytes, ok := usersSecret.Data[egv1a1.BasicAuthUsersSecretKey] + if !ok || len(usersSecretBytes) == 0 { + return nil, fmt.Errorf( + "users secret not found in secret %s/%s", + usersSecret.Namespace, usersSecret.Name) + } + + if err != nil { + return nil, err + } + + return &ir.BasicAuth{Users: usersSecretBytes}, nil +} diff --git a/internal/gatewayapi/testdata/securitypolicy-with-basic-auth.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-basic-auth.in.yaml new file mode 100644 index 00000000000..619425c9bdb --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-basic-auth.in.yaml @@ -0,0 +1,58 @@ +secrets: +- apiVersion: v1 + kind: Secret + metadata: + namespace: default + name: users-secret + data: + .htpasswd: "dXNlcjE6e1NIQX10RVNzQm1FL3lOWTNsYjZhMEw2dlZRRVpOcXc9CnVzZXIyOntTSEF9RUo5TFBGRFhzTjl5blNtYnh2anA3NUJtbHg4PQo=" +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-http-route + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + namespace: default + basicAuth: + users: + name: "users-secret" diff --git a/internal/gatewayapi/testdata/securitypolicy-with-basic-auth.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-basic-auth.out.yaml new file mode 100755 index 00000000000..f8e99c9ae70 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-basic-auth.out.yaml @@ -0,0 +1,152 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: "" + ports: + - containerPort: 10080 + name: http + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + name: envoy-gateway/gateway-1 +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + creationTimestamp: null + name: policy-for-http-route + namespace: default + spec: + basicAuth: + users: + group: null + kind: null + name: users-secret + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + namespace: default + status: + conditions: + - lastTransitionTime: null + message: SecurityPolicy has been accepted. + reason: Accepted + status: "True" + type: Accepted +xdsIR: + envoy-gateway/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: false + name: envoy-gateway/gateway-1/http + port: 10080 + routes: + - backendWeights: + invalid: 0 + valid: 0 + basicAuth: + users: dXNlcjE6e1NIQX10RVNzQm1FL3lOWTNsYjZhMEw2dlZRRVpOcXc9CnVzZXIyOntTSEF9RUo5TFBGRFhzTjl5blNtYnh2anA3NUJtbHg4PQo= + destination: + name: httproute/default/httproute-1/rule/0 + settings: + - endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /foo diff --git a/internal/ir/xds.go b/internal/ir/xds.go index a0f8c821fcc..65f795d339b 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -156,6 +156,9 @@ func (x Xds) Printable() *Xds { if route.OIDC != nil { route.OIDC.ClientSecret = []byte{} } + if route.BasicAuth != nil { + route.BasicAuth.Users = []byte{} + } } } return out @@ -295,6 +298,8 @@ type HTTPRoute struct { OIDC *OIDC `json:"oidc,omitempty" yaml:"oidc,omitempty"` // Proxy Protocol Settings ProxyProtocol *ProxyProtocol `json:"proxyProtocol,omitempty" yaml:"proxyProtocol,omitempty"` + // BasicAuth defines the schema for the HTTP Basic Authentication. + BasicAuth *BasicAuth `json:"basicAuth,omitempty" yaml:"basicAuth,omitempty"` // ExtensionRefs holds unstructured resources that were introduced by an extension and used on the HTTPRoute as extensionRef filters ExtensionRefs []*UnstructuredRef `json:"extensionRefs,omitempty" yaml:"extensionRefs,omitempty"` } @@ -357,6 +362,14 @@ type OIDC struct { Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` } +// BasicAuth defines the schema for the HTTP Basic Authentication. +// +// +k8s:deepcopy-gen=true +type BasicAuth struct { + // The username-password pairs in htpasswd format. + Users []byte `json:"users,omitempty" yaml:"users,omitempty"` +} + type OIDCProvider struct { // The OIDC Provider's [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index d68471b263a..420eb5f8906 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -77,6 +77,26 @@ func (in *AddHeader) DeepCopy() *AddHeader { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BasicAuth) DeepCopyInto(out *BasicAuth) { + *out = *in + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]byte, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BasicAuth. +func (in *BasicAuth) DeepCopy() *BasicAuth { + if in == nil { + return nil + } + out := new(BasicAuth) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CORS) DeepCopyInto(out *CORS) { *out = *in @@ -471,6 +491,11 @@ func (in *HTTPRoute) DeepCopyInto(out *HTTPRoute) { *out = new(ProxyProtocol) **out = **in } + if in.BasicAuth != nil { + in, out := &in.BasicAuth, &out.BasicAuth + *out = new(BasicAuth) + (*in).DeepCopyInto(*out) + } if in.ExtensionRefs != nil { in, out := &in.ExtensionRefs, &out.ExtensionRefs *out = make([]*UnstructuredRef, len(*in)) diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go index cd54c0a4dd3..7eee07267c4 100644 --- a/internal/provider/kubernetes/controller.go +++ b/internal/provider/kubernetes/controller.go @@ -397,55 +397,99 @@ func (r *gatewayAPIReconciler) processSecurityPolicySecretRefs( ctx context.Context, resourceTree *gatewayapi.Resources, resourceMap *resourceMappings) { for _, policy := range resourceTree.SecurityPolicies { oidc := policy.Spec.OIDC + if oidc != nil { - secret := new(corev1.Secret) - secretNamespace := gatewayapi.NamespaceDerefOr(oidc.ClientSecret.Namespace, policy.Namespace) - err := r.client.Get(ctx, - types.NamespacedName{Namespace: secretNamespace, Name: string(oidc.ClientSecret.Name)}, - secret, - ) - if err != nil && !kerrors.IsNotFound(err) { - r.log.Error(err, "unable to find the Secret for OIDC client secret") + if err := r.processSecretRef( + ctx, + resourceMap, + resourceTree, + gatewayapi.KindSecurityPolicy, + policy.Namespace, + policy.Name, + oidc.ClientSecret); err != nil { // we don't return an error here, because we want to continue - // reconciling the rest of the resources despite that this - // SecurityPolicy is invalid. + // reconciling the rest of the SecurityPolicies despite that this + // secret reference is invalid. // This SecurityPolicy will be marked as invalid in its status // when translating to IR because the referenced secret can't be // found. + r.log.Error(err, + "failed to process OIDC SecretRef for SecurityPolicy", + "policy", policy, "secretRef", oidc.ClientSecret) } - - if secretNamespace != policy.Namespace { - from := ObjectKindNamespacedName{ - kind: v1alpha1.KindSecurityPolicy, - namespace: policy.Namespace, - name: policy.Name, - } - to := ObjectKindNamespacedName{ - kind: gatewayapi.KindSecret, - namespace: secretNamespace, - name: secret.Name, - } - refGrant, err := r.findReferenceGrant(ctx, from, to) - switch { - case err != nil: - r.log.Error(err, "failed to find ReferenceGrant") - case refGrant == nil: - r.log.Info("no matching ReferenceGrants found", "from", from.kind, - "from namespace", from.namespace, "target", to.kind, "target namespace", to.namespace) - default: - // RefGrant found - resourceMap.allAssociatedRefGrants[utils.NamespacedName(refGrant)] = refGrant - r.log.Info("added ReferenceGrant to resource map", "namespace", refGrant.Namespace, - "name", refGrant.Name) - } + } + basicAuth := policy.Spec.BasicAuth + if basicAuth != nil { + if err := r.processSecretRef( + ctx, + resourceMap, + resourceTree, + gatewayapi.KindSecurityPolicy, + policy.Namespace, + policy.Name, + basicAuth.Users); err != nil { + r.log.Error(err, + "failed to process BasicAuth SecretRef for SecurityPolicy", + "policy", policy, "secretRef", basicAuth.Users) } - resourceMap.allAssociatedNamespaces[secretNamespace] = struct{}{} // TODO Zhaohuabing do we need this line? - resourceTree.Secrets = append(resourceTree.Secrets, secret) - r.log.Info("processing Secret", "namespace", secretNamespace, "name", string(oidc.ClientSecret.Name)) } } } +// processSecretRef adds the referenced Secret to the resourceTree if it's valid. +// - If it exists in the same namespace as the owner. +// - If it exists in a different namespace, and there is a ReferenceGrant. +func (r *gatewayAPIReconciler) processSecretRef( + ctx context.Context, + resourceMap *resourceMappings, + resourceTree *gatewayapi.Resources, + ownerKind string, + ownerNS string, + ownerName string, + secretRef gwapiv1b1.SecretObjectReference, +) error { + secret := new(corev1.Secret) + secretNS := gatewayapi.NamespaceDerefOr(secretRef.Namespace, ownerNS) + err := r.client.Get(ctx, + types.NamespacedName{Namespace: secretNS, Name: string(secretRef.Name)}, + secret, + ) + if err != nil && !kerrors.IsNotFound(err) { + return fmt.Errorf("unable to find the Secret: %s/%s", secretNS, string(secretRef.Name)) + } + + if secretNS != ownerNS { + from := ObjectKindNamespacedName{ + kind: ownerKind, + namespace: ownerNS, + name: ownerName, + } + to := ObjectKindNamespacedName{ + kind: gatewayapi.KindSecret, + namespace: secretNS, + name: secret.Name, + } + refGrant, err := r.findReferenceGrant(ctx, from, to) + switch { + case err != nil: + return fmt.Errorf("failed to find ReferenceGrant: %v", err) + case refGrant == nil: + return fmt.Errorf( + "no matching ReferenceGrants found: from %s/%s to %s/%s", + from.kind, from.namespace, to.kind, to.namespace) + default: + // RefGrant found + resourceMap.allAssociatedRefGrants[utils.NamespacedName(refGrant)] = refGrant + r.log.Info("added ReferenceGrant to resource map", "namespace", refGrant.Namespace, + "name", refGrant.Name) + } + } + resourceMap.allAssociatedNamespaces[secretNS] = struct{}{} // TODO Zhaohuabing do we need this line? + resourceTree.Secrets = append(resourceTree.Secrets, secret) + r.log.Info("processing Secret", "namespace", secretNS, "name", string(secretRef.Name)) + return nil +} + func (r *gatewayAPIReconciler) gatewayClassUpdater(ctx context.Context, gc *gwapiv1.GatewayClass, accepted bool, reason, msg string) error { if r.statusUpdater != nil { r.statusUpdater.Send(status.Update{ @@ -606,47 +650,19 @@ func (r *gatewayAPIReconciler) processGateways(ctx context.Context, acceptedGC * for _, certRef := range listener.TLS.CertificateRefs { certRef := certRef if refsSecret(&certRef) { - secret := new(corev1.Secret) - secretNamespace := gatewayapi.NamespaceDerefOr(certRef.Namespace, gtw.Namespace) - err := r.client.Get(ctx, - types.NamespacedName{Namespace: secretNamespace, Name: string(certRef.Name)}, - secret, - ) - if err != nil && !kerrors.IsNotFound(err) { - r.log.Error(err, "unable to find Secret") - return err - } - - r.log.Info("processing Secret", "namespace", secretNamespace, "name", string(certRef.Name)) - - if secretNamespace != gtw.Namespace { - from := ObjectKindNamespacedName{ - kind: gatewayapi.KindGateway, - namespace: gtw.Namespace, - name: gtw.Name, - } - to := ObjectKindNamespacedName{ - kind: gatewayapi.KindSecret, - namespace: secretNamespace, - name: string(certRef.Name), - } - refGrant, err := r.findReferenceGrant(ctx, from, to) - switch { - case err != nil: - r.log.Error(err, "failed to find ReferenceGrant") - case refGrant == nil: - r.log.Info("no matching ReferenceGrants found", "from", from.kind, - "from namespace", from.namespace, "target", to.kind, "target namespace", to.namespace) - default: - // RefGrant found - resourceMap.allAssociatedRefGrants[utils.NamespacedName(refGrant)] = refGrant - r.log.Info("added ReferenceGrant to resource map", "namespace", refGrant.Namespace, - "name", refGrant.Name) - } + if err := r.processSecretRef( + ctx, + resourceMap, + resourceTree, + gatewayapi.KindGateway, + gtw.Namespace, + gtw.Name, + certRef); err != nil { + + r.log.Error(err, + "failed to process TLS SecretRef for gateway", + "gateway", gtw, "secretRef", certRef) } - - resourceMap.allAssociatedNamespaces[secretNamespace] = struct{}{} - resourceTree.Secrets = append(resourceTree.Secrets, secret) } } } diff --git a/internal/xds/extensions/extensions.gen.go b/internal/xds/extensions/extensions.gen.go index 4acd57ed18e..ec91f89f958 100644 --- a/internal/xds/extensions/extensions.gen.go +++ b/internal/xds/extensions/extensions.gen.go @@ -94,6 +94,7 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/aws_lambda/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/aws_request_signing/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/bandwidth_limit/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/buffer/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cache/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cdn_loop/v3" @@ -101,6 +102,7 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/compressor/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/connect_grpc_bridge/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cors/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/credential_injector/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/csrf/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/custom_response/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/decompressor/v3" @@ -192,6 +194,8 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/original_ip_detection/xff/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/cookie/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/header/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/injected_credentials/generic/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/injected_credentials/oauth2/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/allow_listed_routes/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/previous_routes/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/internal_redirect/safe_cross_scheme/v3" @@ -238,6 +242,8 @@ import ( _ "github.com/envoyproxy/go-control-plane/envoy/extensions/stat_sinks/graphite_statsd/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/stat_sinks/open_telemetry/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/stat_sinks/wasm/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/tracers/opentelemetry/resource_detectors/v3" + _ "github.com/envoyproxy/go-control-plane/envoy/extensions/tracers/opentelemetry/samplers/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/alts/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/http_11_proxy/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/internal_upstream/v3" diff --git a/internal/xds/translator/basicauth.go b/internal/xds/translator/basicauth.go new file mode 100644 index 00000000000..a7af38551c7 --- /dev/null +++ b/internal/xds/translator/basicauth.go @@ -0,0 +1,189 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "errors" + "fmt" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + basicauthv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + "github.com/tetratelabs/multierror" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +const ( + basicAuthFilter = "envoy.filters.http.basic_auth" +) + +func init() { + registerHTTPFilter(&basicAuth{}) +} + +type basicAuth struct { +} + +var _ httpFilter = &basicAuth{} + +// patchHCM builds and appends the basic_auth Filters to the HTTP Connection Manager +// if applicable, and it does not already exist. +// Note: this method creates an basic_auth filter for each route that contains an BasicAuth config. +func (*basicAuth) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { + var errs error + + if mgr == nil { + return errors.New("hcm is nil") + } + + if irListener == nil { + return errors.New("ir listener is nil") + } + + for _, route := range irListener.Routes { + if !routeContainsBasicAuth(route) { + continue + } + + filter, err := buildHCMBasicAuthFilter(route) + if err != nil { + errs = multierror.Append(errs, err) + continue + } + + // skip if the filter already exists + for _, existingFilter := range mgr.HttpFilters { + if filter.Name == existingFilter.Name { + continue + } + } + + mgr.HttpFilters = append(mgr.HttpFilters, filter) + } + + return nil +} + +// buildHCMBasicAuthFilter returns a basic_auth HTTP filter from the provided IR HTTPRoute. +func buildHCMBasicAuthFilter(route *ir.HTTPRoute) (*hcmv3.HttpFilter, error) { + basicAuthProto := basicAuthConfig(route) + + if err := basicAuthProto.ValidateAll(); err != nil { + return nil, err + } + + basicAuthAny, err := anypb.New(basicAuthProto) + if err != nil { + return nil, err + } + + return &hcmv3.HttpFilter{ + Name: basicAuthFilterName(route), + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: basicAuthAny, + }, + }, nil +} + +func basicAuthFilterName(route *ir.HTTPRoute) string { + return perRouteFilterName(basicAuthFilter, route.Name) +} + +func basicAuthConfig(route *ir.HTTPRoute) *basicauthv3.BasicAuth { + return &basicauthv3.BasicAuth{ + Users: &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineBytes{ + InlineBytes: route.BasicAuth.Users, + }, + }, + } +} + +// routeContainsBasicAuth returns true if BasicAuth exists for the provided route. +func routeContainsBasicAuth(irRoute *ir.HTTPRoute) bool { + if irRoute == nil { + return false + } + + if irRoute != nil && + irRoute.BasicAuth != nil { + return true + } + + return false +} + +func (*basicAuth) patchResources(*types.ResourceVersionTable, []*ir.HTTPRoute) error { + return nil +} + +// patchRouteCfg patches the provided route configuration with the basicAuth filter +// if applicable. +// Note: this method disables all the basicAuth filters by default. The filter will +// be enabled per-route in the typePerFilterConfig of the route. +func (*basicAuth) patchRouteConfig(routeCfg *routev3.RouteConfiguration, irListener *ir.HTTPListener) error { + if routeCfg == nil { + return errors.New("route configuration is nil") + } + if irListener == nil { + return errors.New("ir listener is nil") + } + + var errs error + for _, route := range irListener.Routes { + if !routeContainsBasicAuth(route) { + continue + } + + filterName := basicAuthFilterName(route) + filterCfg := routeCfg.TypedPerFilterConfig + + if _, ok := filterCfg[filterName]; ok { + // This should not happen since this is the only place where the basicAuth + // filter is added in a route. + errs = multierror.Append(errs, fmt.Errorf( + "route config already contains basicAuth config: %+v", route)) + continue + } + + // Disable all the filters by default. The filter will be enabled + // per-route in the typePerFilterConfig of the route. + routeCfgAny, err := anypb.New(&routev3.FilterConfig{Disabled: true}) + if err != nil { + errs = multierror.Append(errs, err) + continue + } + + if filterCfg == nil { + routeCfg.TypedPerFilterConfig = make(map[string]*anypb.Any) + } + + routeCfg.TypedPerFilterConfig[filterName] = routeCfgAny + } + return errs +} + +// patchRoute patches the provided route with the basicAuth config if applicable. +// Note: this method enables the corresponding basicAuth filter for the provided route. +func (*basicAuth) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if irRoute.BasicAuth == nil { + return nil + } + if err := enableFilterOnRoute(basicAuthFilter, route, irRoute); err != nil { + return err + } + return nil +} diff --git a/internal/xds/translator/httpfilters.go b/internal/xds/translator/httpfilters.go index 7fc7e1d9089..f16bb5c09ac 100644 --- a/internal/xds/translator/httpfilters.go +++ b/internal/xds/translator/httpfilters.go @@ -91,12 +91,14 @@ func newOrderedHTTPFilter(filter *hcmv3.HttpFilter) *OrderedHTTPFilter { switch { case filter.Name == wellknown.CORS: order = 1 - case isOAuth2Filter(filter): + case filter.Name == basicAuthFilter: order = 2 - case filter.Name == jwtAuthnFilter: + case isOAuth2Filter(filter): order = 3 - case filter.Name == wellknown.HTTPRateLimit: + case filter.Name == jwtAuthn: order = 4 + case filter.Name == wellknown.HTTPRateLimit: + order = 5 case filter.Name == wellknown.Router: order = 100 } diff --git a/internal/xds/translator/httpfilters_test.go b/internal/xds/translator/httpfilters_test.go index ae4f0452014..d1cce4f5ea1 100644 --- a/internal/xds/translator/httpfilters_test.go +++ b/internal/xds/translator/httpfilters_test.go @@ -24,12 +24,12 @@ func Test_sortHTTPFilters(t *testing.T) { filters: []*hcmv3.HttpFilter{ httpFilterForTest(wellknown.Router), httpFilterForTest(wellknown.CORS), - httpFilterForTest(jwtAuthnFilter), + httpFilterForTest(jwtAuthn), httpFilterForTest(wellknown.HTTPRateLimit), }, want: []*hcmv3.HttpFilter{ httpFilterForTest(wellknown.CORS), - httpFilterForTest(jwtAuthnFilter), + httpFilterForTest(jwtAuthn), httpFilterForTest(wellknown.HTTPRateLimit), httpFilterForTest(wellknown.Router), }, diff --git a/internal/xds/translator/jwt.go b/internal/xds/translator/jwt.go index 13d11e5bf91..619b99b552e 100644 --- a/internal/xds/translator/jwt.go +++ b/internal/xds/translator/jwt.go @@ -25,7 +25,7 @@ import ( ) const ( - jwtAuthnFilter = "envoy.filters.http.jwt_authn" + jwtAuthn = "envoy.filters.http.jwt_authn" envoyTrustBundle = "/etc/ssl/certs/ca-certificates.crt" ) @@ -55,7 +55,7 @@ func (*jwt) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListen // Return early if filter already exists. for _, httpFilter := range mgr.HttpFilters { - if httpFilter.Name == jwtAuthnFilter { + if httpFilter.Name == jwtAuthn { return nil } } @@ -88,7 +88,7 @@ func buildHCMJWTFilter(irListener *ir.HTTPListener) (*hcmv3.HttpFilter, error) { } return &hcmv3.HttpFilter{ - Name: jwtAuthnFilter, + Name: jwtAuthn, ConfigType: &hcmv3.HttpFilter_TypedConfig{ TypedConfig: jwtAuthnAny, }, @@ -217,7 +217,7 @@ func (*jwt) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { } filterCfg := route.GetTypedPerFilterConfig() - if _, ok := filterCfg[jwtAuthnFilter]; !ok { + if _, ok := filterCfg[jwtAuthn]; !ok { if !routeContainsJWTAuthn(irRoute) { return nil } @@ -234,7 +234,7 @@ func (*jwt) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { route.TypedPerFilterConfig = make(map[string]*anypb.Any) } - route.TypedPerFilterConfig[jwtAuthnFilter] = routeCfgAny + route.TypedPerFilterConfig[jwtAuthn] = routeCfgAny } return nil diff --git a/internal/xds/translator/oidc.go b/internal/xds/translator/oidc.go index c422049a634..abed8228b86 100644 --- a/internal/xds/translator/oidc.go +++ b/internal/xds/translator/oidc.go @@ -380,10 +380,10 @@ func (*oidc) patchRouteConfig(routeCfg *routev3.RouteConfiguration, irListener * continue } - perRouteFilterName := oauth2FilterName(route) + filterName := oauth2FilterName(route) filterCfg := routeCfg.TypedPerFilterConfig - if _, ok := filterCfg[perRouteFilterName]; ok { + if _, ok := filterCfg[filterName]; ok { // This should not happen since this is the only place where the oauth2 // filter is added in a route. errs = multierror.Append(errs, fmt.Errorf( @@ -403,7 +403,7 @@ func (*oidc) patchRouteConfig(routeCfg *routev3.RouteConfiguration, irListener * routeCfg.TypedPerFilterConfig = make(map[string]*anypb.Any) } - routeCfg.TypedPerFilterConfig[perRouteFilterName] = routeCfgAny + routeCfg.TypedPerFilterConfig[filterName] = routeCfgAny } return errs } @@ -421,27 +421,8 @@ func (*oidc) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { return nil } - perRouteFilterName := oauth2FilterName(irRoute) - filterCfg := route.GetTypedPerFilterConfig() - if _, ok := filterCfg[perRouteFilterName]; ok { - // This should not happen since this is the only place where the oauth2 - // filter is added in a route. - return fmt.Errorf("route already contains oauth2 config: %+v", route) - } - - // Enable the corresponding oauth2 filter for this route. - routeCfgAny, err := anypb.New(&routev3.FilterConfig{ - Config: &anypb.Any{}, - }) - if err != nil { + if err := enableFilterOnRoute(oauth2Filter, route, irRoute); err != nil { return err } - - if filterCfg == nil { - route.TypedPerFilterConfig = make(map[string]*anypb.Any) - } - - route.TypedPerFilterConfig[perRouteFilterName] = routeCfgAny - return nil } diff --git a/internal/xds/translator/testdata/in/xds-ir/basic-auth.yaml b/internal/xds/translator/testdata/in/xds-ir/basic-auth.yaml new file mode 100644 index 00000000000..2e05dcb6c4c --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/basic-auth.yaml @@ -0,0 +1,19 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + routes: + - name: "first-route" + hostname: "*" + pathMatch: + exact: "foo/bar" + destination: + name: "first-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + basicAuth: + users: "dXNlcjE6e1NIQX10RVNzQm1FL3lOWTNsYjZhMEw2dlZRRVpOcXc9CnVzZXIyOntTSEF9RUo5TFBGRFhzTjl5blNtYnh2anA3NUJtbHg4PQo=" diff --git a/internal/xds/translator/testdata/out/xds-ir/basic-auth.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/basic-auth.clusters.yaml new file mode 100755 index 00000000000..c8692b81602 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/basic-auth.clusters.yaml @@ -0,0 +1,14 @@ +- commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: first-route-dest + lbPolicy: LEAST_REQUEST + name: first-route-dest + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/basic-auth.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/basic-auth.endpoints.yaml new file mode 100755 index 00000000000..3b3f2d09076 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/basic-auth.endpoints.yaml @@ -0,0 +1,12 @@ +- clusterName: first-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: first-route-dest/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/basic-auth.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/basic-auth.listeners.yaml new file mode 100755 index 00000000000..ab0592b760b --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/basic-auth.listeners.yaml @@ -0,0 +1,38 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.basic_auth_first-route + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.basic_auth.v3.BasicAuth + users: + inlineBytes: dXNlcjE6e1NIQX10RVNzQm1FL3lOWTNsYjZhMEw2dlZRRVpOcXc9CnVzZXIyOntTSEF9RUo5TFBGRFhzTjl5blNtYnh2anA3NUJtbHg4PQo= + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: first-listener + statPrefix: http + upgradeConfigs: + - upgradeType: websocket + useRemoteAddress: true + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/basic-auth.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/basic-auth.routes.yaml new file mode 100755 index 00000000000..dbaae69d06a --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/basic-auth.routes.yaml @@ -0,0 +1,20 @@ +- ignorePortInHostMatching: true + name: first-listener + typedPerFilterConfig: + envoy.filters.http.basic_auth_first-route: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + virtualHosts: + - domains: + - '*' + name: first-listener/* + routes: + - match: + path: foo/bar + name: first-route + route: + cluster: first-route-dest + typedPerFilterConfig: + envoy.filters.http.basic_auth_first-route: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} diff --git a/internal/xds/translator/translator_test.go b/internal/xds/translator/translator_test.go index eb7f6f79cf5..731dcb31a2f 100644 --- a/internal/xds/translator/translator_test.go +++ b/internal/xds/translator/translator_test.go @@ -205,6 +205,9 @@ func TestTranslateXds(t *testing.T) { { name: "proxy-protocol-upstream", }, + { + name: "basic-auth", + }, } for _, tc := range testCases { diff --git a/internal/xds/translator/shared_types.go b/internal/xds/translator/utils.go similarity index 50% rename from internal/xds/translator/shared_types.go rename to internal/xds/translator/utils.go index ba326e6271e..407d646408c 100644 --- a/internal/xds/translator/shared_types.go +++ b/internal/xds/translator/utils.go @@ -6,11 +6,17 @@ package translator import ( + "errors" "fmt" "net" "net/url" "strconv" "strings" + + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/envoyproxy/gateway/internal/ir" ) const ( @@ -62,3 +68,42 @@ func url2Cluster(strURL string) (*urlCluster, error) { endpointType: epType, }, nil } + +// enableFilterOnRoute enables a filterType on the provided route. +func enableFilterOnRoute(filterType string, route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + + filterName := perRouteFilterName(filterType, irRoute.Name) + filterCfg := route.GetTypedPerFilterConfig() + if _, ok := filterCfg[filterName]; ok { + // This should not happen since this is the only place where the filter + // config is added in a route. + return fmt.Errorf("route already contains filter config: %s, %+v", + filterType, route) + } + + // Enable the corresponding filter for this route. + routeCfgAny, err := anypb.New(&routev3.FilterConfig{ + Config: &anypb.Any{}, + }) + if err != nil { + return err + } + + if filterCfg == nil { + route.TypedPerFilterConfig = make(map[string]*anypb.Any) + } + + route.TypedPerFilterConfig[filterName] = routeCfgAny + + return nil +} + +func perRouteFilterName(filterType, routeName string) string { + return fmt.Sprintf("%s_%s", filterType, routeName) +} diff --git a/test/e2e/testdata/basic-auth.yaml b/test/e2e/testdata/basic-auth.yaml new file mode 100644 index 00000000000..8af775e17bc --- /dev/null +++ b/test/e2e/testdata/basic-auth.yaml @@ -0,0 +1,80 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + namespace: gateway-conformance-infra + name: basic-auth-users-secret +data: + .htpasswd: "dXNlcjE6e1NIQX10RVNzQm1FL3lOWTNsYjZhMEw2dlZRRVpOcXc9CnVzZXIyOntTSEF9RUo5TFBGRFhzTjl5blNtYnh2anA3NUJtbHg4PQo=" +--- +apiVersion: v1 +kind: Secret +metadata: + namespace: gateway-conformance-infra + name: basic-auth-users-secret-2 +data: + .htpasswd: "dXNlcjM6e1NIQX1QcitqQWR4WkdXOFlXVHhGNVJrb2VpTXBkWWs9CnVzZXI0OntTSEF9SC9LemNFcnQ0RTdzdFI1UXltbU8vVkNoTjVzPQ==" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-with-basic-auth + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: Exact + value: /basic-auth + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-with-basic-auth-2 + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: Exact + value: /basic-auth-2 + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: SecurityPolicy +metadata: + name: basic-auth + namespace: gateway-conformance-infra +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: http-with-basic-auth + namespace: gateway-conformance-infra + basicAuth: + users: + name: "basic-auth-users-secret" +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: SecurityPolicy +metadata: + name: basic-auth-2 + namespace: gateway-conformance-infra +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: http-with-basic-auth-2 + namespace: gateway-conformance-infra + basicAuth: + users: + name: "basic-auth-users-secret-2" diff --git a/test/e2e/tests/basic-auth.go b/test/e2e/tests/basic-auth.go new file mode 100644 index 00000000000..e6701988de4 --- /dev/null +++ b/test/e2e/tests/basic-auth.go @@ -0,0 +1,182 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e +// +build e2e + +package tests + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" +) + +func init() { + ConformanceTests = append(ConformanceTests, BasicAuthTest) +} + +var BasicAuthTest = suite.ConformanceTest{ + ShortName: "BasicAuth", + Description: "Resource with BasicAuth enabled", + Manifests: []string{"testdata/basic-auth.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("valid username password", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-basic-auth", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + SecurityPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "basic-auth", Namespace: ns}) + SecurityPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "basic-auth-2", Namespace: ns}) + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/basic-auth", + Headers: map[string]string{ + "Authorization": "Basic dXNlcjE6dGVzdDE=", // user1:test1 + }, + }, + Response: http.Response{ + StatusCode: 200, + }, + Namespace: ns, + } + + req := http.MakeRequest(t, &expectedResponse, gwAddr, "HTTP", "http") + cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Errorf("failed to get expected response: %v", err) + } + + if err := http.CompareRequest(t, &req, cReq, cResp, expectedResponse); err != nil { + t.Errorf("failed to compare request and response: %v", err) + } + }) + + t.Run("without Authorization header", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-basic-auth", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/basic-auth", + }, + Response: http.Response{ + StatusCode: 401, + }, + Namespace: ns, + } + + req := http.MakeRequest(t, &expectedResponse, gwAddr, "HTTP", "http") + cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Errorf("failed to get expected response: %v", err) + } + + if err := http.CompareRequest(t, &req, cReq, cResp, expectedResponse); err != nil { + t.Errorf("failed to compare request and response: %v", err) + } + }) + + t.Run("invalid username password", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-basic-auth", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/basic-auth", + Headers: map[string]string{ + "Authorization": "Basic dXNlcjE6dGVzdDI=", // user1:test2 + }, + }, + Response: http.Response{ + StatusCode: 401, + }, + Namespace: ns, + } + + req := http.MakeRequest(t, &expectedResponse, gwAddr, "HTTP", "http") + cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Errorf("failed to get expected response: %v", err) + } + + if err := http.CompareRequest(t, &req, cReq, cResp, expectedResponse); err != nil { + t.Errorf("failed to compare request and response: %v", err) + } + }) + + t.Run("per route configuration second route", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-basic-auth-2", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/basic-auth-2", + Headers: map[string]string{ + "Authorization": "Basic dXNlcjQ6dGVzdDQ=", // user4:test4 + }, + }, + Response: http.Response{ + StatusCode: 200, + }, + Namespace: ns, + } + + req := http.MakeRequest(t, &expectedResponse, gwAddr, "HTTP", "http") + cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Errorf("failed to get expected response: %v", err) + } + + if err := http.CompareRequest(t, &req, cReq, cResp, expectedResponse); err != nil { + t.Errorf("failed to compare request and response: %v", err) + } + }) + }, +} + +// SecurityPolicyMustBeAccepted waits for the specified SecurityPolicy to be accepted. +func SecurityPolicyMustBeAccepted( + t *testing.T, + client client.Client, + securityPolicyName types.NamespacedName) { + t.Helper() + + waitErr := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + securityPolicy := &egv1a1.SecurityPolicy{} + err := client.Get(ctx, securityPolicyName, securityPolicy) + if err != nil { + return false, fmt.Errorf("error fetching SecurityPolicy: %w", err) + } + + for _, condition := range securityPolicy.Status.Conditions { + if condition.Type == string(gwv1a2.PolicyConditionAccepted) && condition.Status == metav1.ConditionTrue { + return true, nil + } + } + t.Logf("SecurityPolicy not yet accepted: %v", securityPolicy) + return false, nil + }) + require.NoErrorf(t, waitErr, "error waiting for HTTPRoute to have parents matching expectations") +}