From 7df811084cf7e5c667a189eb2575e3c120f0e8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Burzy=C5=84ski?= Date: Fri, 29 Sep 2023 19:43:12 +0200 Subject: [PATCH] chore(admission): adapt admission server to controller-runtime --- go.mod | 1 - go.sum | 2 - .../admission/custom_validator_adapter.go | 51 ++ internal/admission/gateway_validator.go | 58 +++ internal/admission/handler.go | 338 ------------- internal/admission/httproute_validator.go | 84 ++++ internal/admission/ingress_validator.go | 58 +++ .../kong_cluster_plugin_validator.go | 60 +++ .../kong_consumer_group_validator.go | 76 +++ internal/admission/kong_consumer_validator.go | 220 +++++++++ internal/admission/kong_ingress_validator.go | 43 ++ internal/admission/kong_plugin_validator.go | 93 ++++ internal/admission/secret_validator.go | 83 ++++ internal/admission/server.go | 37 +- internal/admission/server_test.go | 376 --------------- internal/admission/utils.go | 49 -- internal/admission/validator.go | 447 ------------------ internal/manager/run.go | 2 +- internal/manager/setup.go | 125 ++++- test/envtest/kongingress_webhook_test.go | 61 +++ 20 files changed, 1000 insertions(+), 1264 deletions(-) create mode 100644 internal/admission/custom_validator_adapter.go create mode 100644 internal/admission/gateway_validator.go delete mode 100644 internal/admission/handler.go create mode 100644 internal/admission/httproute_validator.go create mode 100644 internal/admission/ingress_validator.go create mode 100644 internal/admission/kong_cluster_plugin_validator.go create mode 100644 internal/admission/kong_consumer_group_validator.go create mode 100644 internal/admission/kong_consumer_validator.go create mode 100644 internal/admission/kong_ingress_validator.go create mode 100644 internal/admission/kong_plugin_validator.go create mode 100644 internal/admission/secret_validator.go delete mode 100644 internal/admission/server_test.go create mode 100644 test/envtest/kongingress_webhook_test.go diff --git a/go.mod b/go.mod index 5fc47fae648..0b6ef2f0e80 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,6 @@ require ( github.com/kong/go-kong v0.47.0 github.com/kong/kubernetes-telemetry v0.1.0 github.com/kong/kubernetes-testing-framework v0.39.1 - github.com/lithammer/dedent v1.1.0 github.com/miekg/dns v1.1.56 github.com/mitchellh/mapstructure v1.5.0 github.com/moul/pb v0.0.0-20220425114252-bca18df4138c diff --git a/go.sum b/go.sum index 9a359766f6b..e4caf72055a 100644 --- a/go.sum +++ b/go.sum @@ -249,8 +249,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= -github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= diff --git a/internal/admission/custom_validator_adapter.go b/internal/admission/custom_validator_adapter.go new file mode 100644 index 00000000000..bbae502db90 --- /dev/null +++ b/internal/admission/custom_validator_adapter.go @@ -0,0 +1,51 @@ +package admission + +import ( + "context" + "errors" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// CustomValidatorAdapter is an adapter for legacy validators in our codebase that makes them compatible with +// the new controller-runtime's CustomValidator interface. +type CustomValidatorAdapter struct { + validateCreate func(ctx context.Context, obj runtime.Object) (bool, string, error) + validateUpdate func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) + validateDelete func(ctx context.Context, obj runtime.Object) (bool, string, error) +} + +func (c CustomValidatorAdapter) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + if c.validateCreate == nil { + return admission.Warnings{}, nil + } + return c.returnValues(c.validateCreate(ctx, obj)) +} + +func (c CustomValidatorAdapter) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { + if c.validateUpdate == nil { + return admission.Warnings{}, nil + } + return c.returnValues(c.validateUpdate(ctx, oldObj, newObj)) +} + +func (c CustomValidatorAdapter) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + if c.validateDelete == nil { + return admission.Warnings{}, nil + } + return c.returnValues(c.validateDelete(ctx, obj)) +} + +func (c CustomValidatorAdapter) returnValues(ok bool, message string, err error) (admission.Warnings, error) { + if err != nil { + return admission.Warnings{message}, err + } + if !ok { + return admission.Warnings{message}, errors.New(message) + } + if message != "" { + return admission.Warnings{message}, nil + } + return admission.Warnings{}, nil +} diff --git a/internal/admission/gateway_validator.go b/internal/admission/gateway_validator.go new file mode 100644 index 00000000000..ad75d58b87c --- /dev/null +++ b/internal/admission/gateway_validator.go @@ -0,0 +1,58 @@ +package admission + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + gatewaycontroller "github.com/kong/kubernetes-ingress-controller/v2/internal/controllers/gateway" + "github.com/kong/kubernetes-ingress-controller/v2/internal/gatewayapi" +) + +func (validator KongHTTPValidator) Gateway() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + gateway, ok := obj.(*gatewayapi.Gateway) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *gatewayapi.Gateway, got %T", obj) + } + return validator.ValidateGateway(ctx, *gateway) + }, + validateUpdate: func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) { + gateway, ok := newObj.(*gatewayapi.Gateway) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *gatewayapi.Gateway, got %T", newObj) + } + return validator.ValidateGateway(ctx, *gateway) + }, + } +} + +func (validator KongHTTPValidator) ValidateGateway( + ctx context.Context, gateway gatewayapi.Gateway, +) (bool, string, error) { + // check if the gateway declares a gateway class + if gateway.Spec.GatewayClassName == "" { + return true, "", nil + } + + // validate the gatewayclass reference + gwc := gatewayapi.GatewayClass{} + if err := validator.ManagerClient.Get(ctx, client.ObjectKey{Name: string(gateway.Spec.GatewayClassName)}, &gwc); err != nil { + if strings.Contains(err.Error(), "not found") { + return true, "", nil // not managed by this controller + } + return false, ErrTextCantRetrieveGatewayClass, err + } + + // validate whether the gatewayclass is a supported class, if not + // then this gateway belongs to another controller. + if gwc.Spec.ControllerName != gatewaycontroller.GetControllerName() { + return true, "", nil + } + + return true, "", nil +} diff --git a/internal/admission/handler.go b/internal/admission/handler.go deleted file mode 100644 index 81a7fda5b64..00000000000 --- a/internal/admission/handler.go +++ /dev/null @@ -1,338 +0,0 @@ -package admission - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/sirupsen/logrus" - admissionv1 "k8s.io/api/admission/v1" - corev1 "k8s.io/api/core/v1" - netv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" - - "github.com/kong/kubernetes-ingress-controller/v2/internal/gatewayapi" - kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" - kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1beta1" -) - -// RequestHandler is an HTTP server that can validate Kong Ingress Controllers' -// Custom Resources using Kubernetes Admission Webhooks. -type RequestHandler struct { - // Validator validates the entities that the k8s API-server asks - // it the server to validate. - Validator KongValidator - - Logger logrus.FieldLogger -} - -// ServeHTTP parses AdmissionReview requests and responds back -// with the validation result of the entity. -func (h RequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Body == nil { - h.Logger.Error("received request with empty body") - http.Error(w, "admission review object is missing", - http.StatusBadRequest) - return - } - - review := admissionv1.AdmissionReview{} - if err := json.NewDecoder(r.Body).Decode(&review); err != nil { - h.Logger.WithError(err).Error("failed to decode admission review") - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - response, err := h.handleValidation(r.Context(), *review.Request) - if err != nil { - h.Logger.WithError(err).Error("failed to run validation") - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - review.Response = response - - if err := json.NewEncoder(w).Encode(&review); err != nil { - h.Logger.WithError(err).Error("failed to encode response") - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -var ( - consumerGVResource = metav1.GroupVersionResource{ - Group: kongv1.SchemeGroupVersion.Group, - Version: kongv1.SchemeGroupVersion.Version, - Resource: "kongconsumers", - } - consumerGroupGVResource = metav1.GroupVersionResource{ - Group: kongv1beta1.SchemeGroupVersion.Group, - Version: kongv1beta1.SchemeGroupVersion.Version, - Resource: "kongconsumergroups", - } - pluginGVResource = metav1.GroupVersionResource{ - Group: kongv1.SchemeGroupVersion.Group, - Version: kongv1.SchemeGroupVersion.Version, - Resource: "kongplugins", - } - clusterPluginGVResource = metav1.GroupVersionResource{ - Group: kongv1.SchemeGroupVersion.Group, - Version: kongv1.SchemeGroupVersion.Version, - Resource: "kongclusterplugins", - } - kongIngressGVResource = metav1.GroupVersionResource{ - Group: kongv1.SchemeGroupVersion.Group, - Version: kongv1.SchemeGroupVersion.Version, - Resource: "kongingresses", - } - secretGVResource = metav1.GroupVersionResource{ - Group: corev1.SchemeGroupVersion.Group, - Version: corev1.SchemeGroupVersion.Version, - Resource: "secrets", - } - gatewayGVResource = metav1.GroupVersionResource{ - Group: gatewayv1beta1.GroupVersion.Group, - Version: gatewayv1beta1.GroupVersion.Version, - Resource: "gateways", - } - httprouteGVResource = metav1.GroupVersionResource{ - Group: gatewayv1beta1.GroupVersion.Group, - Version: gatewayv1beta1.GroupVersion.Version, - Resource: "httproutes", - } - ingressGVResource = metav1.GroupVersionResource{ - Group: netv1.SchemeGroupVersion.Group, - Version: netv1.SchemeGroupVersion.Version, - Resource: "ingresses", - } -) - -func (h RequestHandler) handleValidation(ctx context.Context, request admissionv1.AdmissionRequest) ( - *admissionv1.AdmissionResponse, error, -) { - responseBuilder := NewResponseBuilder(request.UID) - - switch request.Resource { - case consumerGVResource: - return h.handleKongConsumer(ctx, request, responseBuilder) - case consumerGroupGVResource: - return h.handleKongConsumerGroup(ctx, request, responseBuilder) - case pluginGVResource: - return h.handleKongPlugin(ctx, request, responseBuilder) - case clusterPluginGVResource: - return h.handleKongClusterPlugin(ctx, request, responseBuilder) - case secretGVResource: - return h.handleSecret(ctx, request, responseBuilder) - case gatewayGVResource: - return h.handleGateway(ctx, request, responseBuilder) - case httprouteGVResource: - return h.handleHTTPRoute(ctx, request, responseBuilder) - case kongIngressGVResource: - return h.handleKongIngress(ctx, request, responseBuilder) - case ingressGVResource: - return h.handleIngress(ctx, request, responseBuilder) - default: - return nil, fmt.Errorf("unknown resource type to validate: %s/%s %s", - request.Resource.Group, request.Resource.Version, - request.Resource.Resource) - } -} - -func (h RequestHandler) handleKongConsumer( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - consumer := kongv1.KongConsumer{} - deserializer := codecs.UniversalDeserializer() - _, _, err := deserializer.Decode(request.Object.Raw, nil, &consumer) - if err != nil { - return nil, err - } - - switch request.Operation { //nolint:exhaustive - case admissionv1.Create: - ok, msg, err := h.Validator.ValidateConsumer(ctx, consumer) - if err != nil { - return nil, err - } - return responseBuilder.Allowed(ok).WithMessage(msg).Build(), nil - case admissionv1.Update: - var oldConsumer kongv1.KongConsumer - _, _, err = deserializer.Decode(request.OldObject.Raw, nil, &oldConsumer) - if err != nil { - return nil, err - } - // validate only if the username is being changed - if consumer.Username == oldConsumer.Username { - return responseBuilder.Allowed(true).Build(), nil - } - ok, message, err := h.Validator.ValidateConsumer(ctx, consumer) - if err != nil { - return nil, err - } - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil - default: - return nil, fmt.Errorf("unknown operation %q", string(request.Operation)) - } -} - -func (h RequestHandler) handleKongConsumerGroup( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - var consumerGroup kongv1beta1.KongConsumerGroup - if _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &consumerGroup); err != nil { - return nil, err - } - ok, message, err := h.Validator.ValidateConsumerGroup(ctx, consumerGroup) - if err != nil { - return nil, err - } - - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil -} - -func (h RequestHandler) handleKongPlugin( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - plugin := kongv1.KongPlugin{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &plugin) - if err != nil { - return nil, err - } - - ok, message, err := h.Validator.ValidatePlugin(ctx, plugin) - if err != nil { - return nil, err - } - - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil -} - -func (h RequestHandler) handleKongClusterPlugin( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - plugin := kongv1.KongClusterPlugin{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &plugin) - if err != nil { - return nil, err - } - - ok, message, err := h.Validator.ValidateClusterPlugin(ctx, plugin) - if err != nil { - return nil, err - } - - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil -} - -func (h RequestHandler) handleSecret( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - secret := corev1.Secret{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &secret) - if err != nil { - return nil, err - } - if _, ok := secret.Data["kongCredType"]; !ok { - // secret does not look like a credential resource in Kong - return responseBuilder.Allowed(true).Build(), nil - } - - // secrets are only validated on update because they must be referenced by a - // managed consumer in order for us to validate them, and because credentials - // validation also happens at the consumer side of the reference so a - // credentials secret can not be referenced without being validated. - - switch request.Operation { //nolint:exhaustive - case admissionv1.Update: - ok, message, err := h.Validator.ValidateCredential(ctx, secret) - if err != nil { - return nil, err - } - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil - default: - return nil, fmt.Errorf("unknown operation %q", string(request.Operation)) - } -} - -func (h RequestHandler) handleGateway( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - gateway := gatewayapi.Gateway{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &gateway) - if err != nil { - return nil, err - } - ok, message, err := h.Validator.ValidateGateway(ctx, gateway) - if err != nil { - return nil, err - } - - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil -} - -func (h RequestHandler) handleHTTPRoute( - ctx context.Context, - request admissionv1.AdmissionRequest, - responseBuilder *ResponseBuilder, -) (*admissionv1.AdmissionResponse, error) { - httproute := gatewayapi.HTTPRoute{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &httproute) - if err != nil { - return nil, err - } - ok, message, err := h.Validator.ValidateHTTPRoute(ctx, httproute) - if err != nil { - return nil, err - } - - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil -} - -func (h RequestHandler) handleKongIngress(_ context.Context, request admissionv1.AdmissionRequest, responseBuilder *ResponseBuilder) (*admissionv1.AdmissionResponse, error) { - kongIngress := kongv1.KongIngress{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &kongIngress) - if err != nil { - return nil, err - } - - // KongIngress is always allowed. - responseBuilder = responseBuilder.Allowed(true) - - if kongIngress.Proxy != nil { - const warning = "'proxy' is DEPRECATED. Use Service's annotations instead." - responseBuilder = responseBuilder.WithWarning(warning) - } - - if kongIngress.Route != nil { - const warning = "'route' is DEPRECATED. Use Ingress' annotations instead." - responseBuilder = responseBuilder.WithWarning(warning) - } - - return responseBuilder.Build(), nil -} - -func (h RequestHandler) handleIngress(ctx context.Context, request admissionv1.AdmissionRequest, responseBuilder *ResponseBuilder) (*admissionv1.AdmissionResponse, error) { - ingress := netv1.Ingress{} - _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &ingress) - if err != nil { - return nil, err - } - ok, message, err := h.Validator.ValidateIngress(ctx, ingress) - if err != nil { - return nil, err - } - - return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil -} diff --git a/internal/admission/httproute_validator.go b/internal/admission/httproute_validator.go new file mode 100644 index 00000000000..cbbd95cf326 --- /dev/null +++ b/internal/admission/httproute_validator.go @@ -0,0 +1,84 @@ +package admission + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + gatewayvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/gateway" + gatewaycontroller "github.com/kong/kubernetes-ingress-controller/v2/internal/controllers/gateway" + "github.com/kong/kubernetes-ingress-controller/v2/internal/gatewayapi" +) + +func (validator KongHTTPValidator) HTTPRoute() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + route, ok := obj.(*gatewayapi.HTTPRoute) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *gatewayapi.HTTPRoute, got %T", obj) + } + return validator.ValidateHTTPRoute(ctx, *route) + }, + validateUpdate: func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) { + route, ok := newObj.(*gatewayapi.HTTPRoute) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *gatewayapi.HTTPRoute, got %T", newObj) + } + return validator.ValidateHTTPRoute(ctx, *route) + }, + } +} + +func (validator KongHTTPValidator) ValidateHTTPRoute( + ctx context.Context, httproute gatewayapi.HTTPRoute, +) (bool, string, error) { + // in order to be sure whether or not an HTTPRoute resource is managed by this + // controller we disallow references to Gateway resources that do not exist. + var managedGateways []*gatewayapi.Gateway + for _, parentRef := range httproute.Spec.ParentRefs { + // determine the namespace of the gateway referenced via parentRef. If no + // explicit namespace is provided, assume the namespace of the route. + namespace := httproute.Namespace + if parentRef.Namespace != nil { + namespace = string(*parentRef.Namespace) + } + + // gather the Gateway resource referenced by parentRef and fail validation + // if there is no such Gateway resource. + gateway := gatewayapi.Gateway{} + if err := validator.ManagerClient.Get(ctx, client.ObjectKey{ + Namespace: namespace, + Name: string(parentRef.Name), + }, &gateway); err != nil { + return false, fmt.Sprintf("couldn't retrieve referenced gateway %s/%s", namespace, parentRef.Name), err + } + + // pull the referenced GatewayClass object from the Gateway + gatewayClass := gatewayapi.GatewayClass{} + if err := validator.ManagerClient.Get(ctx, client.ObjectKey{Name: string(gateway.Spec.GatewayClassName)}, &gatewayClass); err != nil { + return false, fmt.Sprintf("couldn't retrieve referenced gatewayclass %s", gateway.Spec.GatewayClassName), err + } + + // determine ultimately whether the Gateway is managed by this controller implementation + if gatewayClass.Spec.ControllerName == gatewaycontroller.GetControllerName() { + managedGateways = append(managedGateways, &gateway) + } + } + + // if there are no managed Gateways this is not a supported HTTPRoute + if len(managedGateways) == 0 { + return true, "", nil + } + + // Now that we know whether or not the HTTPRoute is linked to a managed + // Gateway we can run it through full validation. + var routeValidator routeValidator = noOpRoutesValidator{} + if routesSvc, ok := validator.AdminAPIServicesProvider.GetRoutesService(); ok { + routeValidator = routesSvc + } + return gatewayvalidation.ValidateHTTPRoute( + ctx, routeValidator, validator.ParserFeatures, validator.KongVersion, &httproute, managedGateways..., + ) +} diff --git a/internal/admission/ingress_validator.go b/internal/admission/ingress_validator.go new file mode 100644 index 00000000000..e531e7609ab --- /dev/null +++ b/internal/admission/ingress_validator.go @@ -0,0 +1,58 @@ +package admission + +import ( + "context" + "fmt" + + "github.com/kong/go-kong/kong" + netv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" + + ingressvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/ingress" + "github.com/kong/kubernetes-ingress-controller/v2/internal/annotations" +) + +type routeValidator interface { + Validate(context.Context, *kong.Route) (bool, string, error) +} + +type noOpRoutesValidator struct{} + +func (noOpRoutesValidator) Validate(_ context.Context, _ *kong.Route) (bool, string, error) { + return true, "", nil +} + +func (validator KongHTTPValidator) Ingress() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + ingress, ok := obj.(*netv1.Ingress) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *netv1.Ingress, got %T", obj) + } + return validator.ValidateIngress(ctx, *ingress) + }, + validateUpdate: func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) { + ingress, ok := newObj.(*netv1.Ingress) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *netv1.Ingress, got %T", newObj) + } + return validator.ValidateIngress(ctx, *ingress) + }, + } +} + +func (validator KongHTTPValidator) ValidateIngress( + ctx context.Context, ingress netv1.Ingress, +) (bool, string, error) { + // Ignore Ingresses that are being managed by another controller. + if !validator.ingressClassMatcher(&ingress.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) && + !validator.ingressV1ClassMatcher(&ingress, annotations.ExactClassMatch) { + return true, "", nil + } + + var routeValidator routeValidator = noOpRoutesValidator{} + if routesSvc, ok := validator.AdminAPIServicesProvider.GetRoutesService(); ok { + routeValidator = routesSvc + } + return ingressvalidation.ValidateIngress(ctx, routeValidator, validator.ParserFeatures, validator.KongVersion, &ingress) +} diff --git a/internal/admission/kong_cluster_plugin_validator.go b/internal/admission/kong_cluster_plugin_validator.go new file mode 100644 index 00000000000..a23df31b6c5 --- /dev/null +++ b/internal/admission/kong_cluster_plugin_validator.go @@ -0,0 +1,60 @@ +package admission + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + + kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" +) + +func (validator KongHTTPValidator) KongClusterPlugin() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + plugin, ok := obj.(*kongv1.KongClusterPlugin) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongClusterPlugin, got %T", obj) + } + return validator.ValidateClusterPlugin(ctx, *plugin) + }, + validateUpdate: func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) { + newPlugin, ok := newObj.(*kongv1.KongClusterPlugin) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongClusterPlugin, got %T", newObj) + } + return validator.ValidateClusterPlugin(ctx, *newPlugin) + }, + } +} + +// ValidateClusterPlugin transfers relevant fields from a KongClusterPlugin into a KongPlugin and then returns +// the result of ValidatePlugin for the derived KongPlugin. +func (validator KongHTTPValidator) ValidateClusterPlugin( + ctx context.Context, + k8sPlugin kongv1.KongClusterPlugin, +) (bool, string, error) { + derived := kongv1.KongPlugin{ + TypeMeta: k8sPlugin.TypeMeta, + ObjectMeta: k8sPlugin.ObjectMeta, + ConsumerRef: k8sPlugin.ConsumerRef, + Disabled: k8sPlugin.Disabled, + Config: k8sPlugin.Config, + PluginName: k8sPlugin.PluginName, + RunOn: k8sPlugin.RunOn, + Protocols: k8sPlugin.Protocols, + } + if k8sPlugin.ConfigFrom != nil { + ref := kongv1.ConfigSource{ + SecretValue: kongv1.SecretValueFromSource{ + Secret: k8sPlugin.ConfigFrom.SecretValue.Secret, + Key: k8sPlugin.ConfigFrom.SecretValue.Key, + }, + } + derived.ConfigFrom = &ref + derived.ObjectMeta.Namespace = k8sPlugin.ConfigFrom.SecretValue.Namespace + } else { + derived.ObjectMeta.Namespace = "default" + } + return validator.ValidatePlugin(ctx, derived) +} diff --git a/internal/admission/kong_consumer_group_validator.go b/internal/admission/kong_consumer_group_validator.go new file mode 100644 index 00000000000..02d4d481ff8 --- /dev/null +++ b/internal/admission/kong_consumer_group_validator.go @@ -0,0 +1,76 @@ +package admission + +import ( + "context" + "fmt" + + "github.com/blang/semver/v4" + "github.com/kong/go-kong/kong" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/kong/kubernetes-ingress-controller/v2/internal/annotations" + "github.com/kong/kubernetes-ingress-controller/v2/internal/versions" + kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1beta1" +) + +func (validator KongHTTPValidator) KongConsumerGroup() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + consumerGroup, ok := obj.(*kongv1beta1.KongConsumerGroup) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1beta1.KongConsumerGroup, got %T", obj) + } + return validator.ValidateConsumerGroup(ctx, *consumerGroup) + }, + } +} + +func (validator KongHTTPValidator) ValidateConsumerGroup( + ctx context.Context, + consumerGroup kongv1beta1.KongConsumerGroup, +) (bool, string, error) { + // Ignore ConsumerGroups that are being managed by another controller. + if !validator.ingressClassMatcher(&consumerGroup.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) { + return true, "", nil + } + + // Consumer groups work only for Kong Enterprise >=3.4. + infoSvc, ok := validator.AdminAPIServicesProvider.GetInfoService() + if !ok { + return true, "", nil + } + info, err := infoSvc.Get(ctx) + if err != nil { + validator.Logger.Debugf("failed to fetch Kong info: %v", err) + return false, ErrTextAdminAPIUnavailable, nil + } + version, err := kong.NewVersion(info.Version) + if err != nil { + validator.Logger.Debugf("failed to parse Kong version: %v", err) + } else { + kongVer := semver.Version{Major: version.Major(), Minor: version.Minor()} + if !version.IsKongGatewayEnterprise() || !kongVer.GTE(versions.ConsumerGroupsVersionCutoff) { + return false, ErrTextConsumerGroupUnsupported, nil + } + } + + cgs, ok := validator.AdminAPIServicesProvider.GetConsumerGroupsService() + if !ok { + return true, "", nil + } + // This check forbids consumer group creation if the license is invalid or missing. + // There is no other way to robustly check the validity of a license than actually trying an enterprise feature. + if _, _, err := cgs.List(ctx, &kong.ListOpt{Size: 0}); err != nil { + switch { + case kong.IsNotFoundErr(err): + // This is the case when consumer group is not supported (Kong OSS) and previous version + // check (if !version.IsKongGatewayEnterprise()) has been omitted due to a parsing error. + return false, ErrTextConsumerGroupUnsupported, nil + case kong.IsForbiddenErr(err): + return false, ErrTextConsumerGroupUnlicensed, nil + default: + return false, fmt.Sprintf("%s: %s", ErrTextConsumerGroupUnexpected, err), nil + } + } + return true, "", nil +} diff --git a/internal/admission/kong_consumer_validator.go b/internal/admission/kong_consumer_validator.go new file mode 100644 index 00000000000..79300de6acd --- /dev/null +++ b/internal/admission/kong_consumer_validator.go @@ -0,0 +1,220 @@ +package admission + +import ( + "context" + "fmt" + + "github.com/kong/go-kong/kong" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + credsvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/consumers/credentials" + "github.com/kong/kubernetes-ingress-controller/v2/internal/annotations" + kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" +) + +func (validator KongHTTPValidator) KongConsumer() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + consumer, ok := obj.(*kongv1.KongConsumer) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongConsumer, got %T", obj) + } + return validator.ValidateConsumer(ctx, *consumer) + }, + validateUpdate: func(ctx context.Context, oldObj, newObj runtime.Object) (bool, string, error) { + oldConsumer, ok := oldObj.(*kongv1.KongConsumer) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongConsumer, got %T", oldObj) + } + newConsumer, ok := newObj.(*kongv1.KongConsumer) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongConsumer, got %T", newObj) + } + // Only validate if the username has changed. + if oldConsumer.Username != newConsumer.Username { + return false, "", nil + } + + return validator.ValidateConsumer(ctx, *newConsumer) + }, + } +} + +// ValidateConsumer checks if consumer has a Username and a consumer with +// the same username doesn't exist in Kong. +// If an error occurs during validation, it is returned as the last argument. +// The first boolean communicates if the consumer is valid or not and string +// holds a message if the entity is not valid. +func (validator KongHTTPValidator) ValidateConsumer( + ctx context.Context, + consumer kongv1.KongConsumer, +) (bool, string, error) { + // ignore consumers that are being managed by another controller + if !validator.ingressClassMatcher(&consumer.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) { + return true, "", nil + } + + // a consumer without a username is not valid + if consumer.Username == "" { + return false, ErrTextConsumerUsernameEmpty, nil + } + + errText, err := validator.ensureConsumerDoesNotExistInGateway(ctx, consumer.Username) + if err != nil || errText != "" { + return false, errText, err + } + + // if there are no credentials for this consumer, there's no need to move on + // to credentials validation. + if len(consumer.Credentials) == 0 { + return true, "", nil + } + + // pull all the managed consumers in order to build a validation index of + // credentials so that the consumers credentials references can be validated. + managedConsumers, err := validator.listManagedConsumers(ctx) + if err != nil { + return false, ErrTextConsumerUnretrievable, err + } + + // retrieve the consumer's credentials secrets to validate them with the index + credentials := make([]*corev1.Secret, 0, len(consumer.Credentials)) + ignoredSecrets := make(map[string]map[string]struct{}) + for _, secretName := range consumer.Credentials { + // retrieve the credentials secret + secret, err := validator.SecretGetter.GetSecret(consumer.Namespace, secretName) + if err != nil { + if apierrors.IsNotFound(err) { + return false, ErrTextConsumerCredentialSecretNotFound, err + } + return false, ErrTextFailedToRetrieveSecret, err + } + + // do the basic credentials validation + if err := credsvalidation.ValidateCredentials(secret); err != nil { + return false, ErrTextConsumerCredentialValidationFailed, err + } + + // if valid, store it so we can index it for upcoming constraints validation + credentials = append(credentials, secret) + + // later we'll build a global index of all credentials which is needed to + // validate unique key constraints. That index should omit the secrets that + // are referenced by this consumer to avoid duplication. + if _, ok := ignoredSecrets[consumer.Namespace]; !ok { + ignoredSecrets[consumer.Namespace] = make(map[string]struct{}, len(consumer.Credentials)) + } + ignoredSecrets[consumer.Namespace][secretName] = struct{}{} + } + + // unique constraints on consumer credentials are global to all consumers + // and credentials, so we must build an index based on all existing credentials. + // we ignore the secrets referenced by this consumer so that the index is not + // testing them against themselves. + credentialsIndex, err := globalValidationIndexForCredentials(ctx, validator.ManagerClient, managedConsumers, ignoredSecrets) + if err != nil { + return false, ErrTextConsumerCredentialValidationFailed, err + } + + // validate the consumer's credentials against the index of all managed + // credentials to ensure they're not in violation of any unique constraints. + for _, secret := range credentials { + // do the unique constraints validation of the credentials using the credentials index + if err := credentialsIndex.ValidateCredentialsForUniqueKeyConstraints(secret); err != nil { + return false, ErrTextConsumerCredentialValidationFailed, err + } + } + + return true, "", nil +} + +func (validator KongHTTPValidator) ensureConsumerDoesNotExistInGateway(ctx context.Context, username string) (string, error) { + if consumerSvc, hasClient := validator.AdminAPIServicesProvider.GetConsumersService(); hasClient { + // verify that the consumer is not already present in the data-plane + c, err := consumerSvc.Get(ctx, &username) + if err != nil { + if !kong.IsNotFoundErr(err) { + validator.Logger.WithError(err).Error("failed to fetch consumer from kong") + return ErrTextConsumerUnretrievable, err + } + } + if c != nil { + return ErrTextConsumerExists, nil + } + } + + // if there's no client, do not verify existence with data-plane as there's none available + return "", nil +} + +func (validator KongHTTPValidator) listManagedConsumers(ctx context.Context) ([]*kongv1.KongConsumer, error) { + // gather a list of all consumers from the cached client + consumers := &kongv1.KongConsumerList{} + if err := validator.ManagerClient.List(ctx, consumers, &client.ListOptions{ + Namespace: corev1.NamespaceAll, + }); err != nil { + return nil, err + } + + // reduce the consumer set to consumers managed by this controller + managedConsumers := make([]*kongv1.KongConsumer, 0) + for _, consumer := range consumers.Items { + consumer := consumer + if !validator.ingressClassMatcher(&consumer.ObjectMeta, annotations.IngressClassKey, + annotations.ExactClassMatch) { + // ignore consumers (and subsequently secrets) that are managed by other controllers + continue + } + consumerCopy := consumer + managedConsumers = append(managedConsumers, &consumerCopy) + } + + return managedConsumers, nil +} + +// globalValidationIndexForCredentials builds an index of all consumer credentials +// using a given controller-runtime client. This provides an index based on +// ALL namespaces in the cluster. This can be very expensive with high numbers +// of consumer credentials, particularly if the client you provide is not cached +// +// if the caller is building the index to validate updates for specific secrets +// and those secrets should be excluded from the index because they will be added +// later, a map of the namespace and name of those secrets can be provided to exclude them. +func globalValidationIndexForCredentials(ctx context.Context, managerClient client.Client, consumers []*kongv1.KongConsumer, ignoredSecrets map[string]map[string]struct{}) (credsvalidation.Index, error) { + // pull the reference secrets for credentials from each consumer in the list + index := make(credsvalidation.Index) + for _, consumer := range consumers { + for _, secretName := range consumer.Credentials { + // if its been requested that this secret be specifically ignored + // (e.g. that secret is being updated and will soon have new values) + // then don't add it to the index. + if secrets, namespaceContainsSkippedSecrets := ignoredSecrets[consumer.Namespace]; namespaceContainsSkippedSecrets { + if _, secretShouldBeSkipped := secrets[secretName]; secretShouldBeSkipped { + continue + } + } + + // grab a copy of the credential secret + secret := &corev1.Secret{} + if err := managerClient.Get(ctx, client.ObjectKey{ + Namespace: consumer.Namespace, + Name: secretName, + }, secret); err != nil { + if apierrors.IsNotFound(err) { // ignore missing secrets + continue + } + return nil, err + } + + // add the credential secret to the index + if err := index.ValidateCredentialsForUniqueKeyConstraints(secret); err != nil { + return nil, err + } + } + } + + return index, nil +} diff --git a/internal/admission/kong_ingress_validator.go b/internal/admission/kong_ingress_validator.go new file mode 100644 index 00000000000..80190fd41b8 --- /dev/null +++ b/internal/admission/kong_ingress_validator.go @@ -0,0 +1,43 @@ +package admission + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" +) + +type KongIngressValidator struct{} + +func (k KongIngressValidator) ValidateCreate(_ context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { + return k.validate(obj) +} + +func (k KongIngressValidator) ValidateUpdate(_ context.Context, _, newObj runtime.Object) (warnings admission.Warnings, err error) { + return k.validate(newObj) +} + +func (k KongIngressValidator) ValidateDelete(context.Context, runtime.Object) (warnings admission.Warnings, err error) { + return admission.Warnings{}, nil +} + +func (k KongIngressValidator) validate(obj runtime.Object) (warnings admission.Warnings, err error) { + ingress, ok := obj.(*kongv1.KongIngress) + if !ok { + return admission.Warnings{}, fmt.Errorf("unexpected type, expected *kongv1.KongIngress, got %T", obj) + } + + if ingress.Proxy != nil { + const warning = "'proxy' ids DEPRECATED. Use Service's annotations instead." + warnings = append(warnings, warning) + } + if ingress.Route != nil { + const warning = "'route' is DEPRECATED. Use Ingress' annotations instead." + warnings = append(warnings, warning) + } + + return warnings, nil +} diff --git a/internal/admission/kong_plugin_validator.go b/internal/admission/kong_plugin_validator.go new file mode 100644 index 00000000000..1dd82c015cc --- /dev/null +++ b/internal/admission/kong_plugin_validator.go @@ -0,0 +1,93 @@ +package admission + +import ( + "context" + "fmt" + + "github.com/kong/go-kong/kong" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/kongstate" + kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" +) + +func (validator KongHTTPValidator) KongPlugin() CustomValidatorAdapter { + return CustomValidatorAdapter{ + validateCreate: func(ctx context.Context, obj runtime.Object) (bool, string, error) { + plugin, ok := obj.(*kongv1.KongPlugin) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongPlugin, got %T", obj) + } + return validator.ValidatePlugin(ctx, *plugin) + }, + validateUpdate: func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) { + newPlugin, ok := newObj.(*kongv1.KongPlugin) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *kongv1.KongPlugin, got %T", newObj) + } + return validator.ValidatePlugin(ctx, *newPlugin) + }, + } +} + +// ValidatePlugin checks if k8sPlugin is valid. It does so by performing +// an HTTP request to Kong's Admin API entity validation endpoints. +// If an error occurs during validation, it is returned as the last argument. +// The first boolean communicates if k8sPluign is valid or not and string +// holds a message if the entity is not valid. +func (validator KongHTTPValidator) ValidatePlugin( + ctx context.Context, + k8sPlugin kongv1.KongPlugin, +) (bool, string, error) { + if k8sPlugin.PluginName == "" { + return false, ErrTextPluginNameEmpty, nil + } + var plugin kong.Plugin + plugin.Name = kong.String(k8sPlugin.PluginName) + var err error + plugin.Config, err = kongstate.RawConfigToConfiguration(k8sPlugin.Config) + if err != nil { + return false, ErrTextPluginConfigInvalid, err + } + if k8sPlugin.ConfigFrom != nil { + if len(plugin.Config) > 0 { + return false, ErrTextPluginUsesBothConfigTypes, nil + } + config, err := kongstate.SecretToConfiguration(validator.SecretGetter, (*k8sPlugin.ConfigFrom).SecretValue, k8sPlugin.Namespace) + if err != nil { + return false, ErrTextPluginSecretConfigUnretrievable, err + } + plugin.Config = config + } + if k8sPlugin.RunOn != "" { + plugin.RunOn = kong.String(k8sPlugin.RunOn) + } + if k8sPlugin.Ordering != nil { + plugin.Ordering = k8sPlugin.Ordering + } + if len(k8sPlugin.Protocols) > 0 { + plugin.Protocols = kong.StringSlice(kongv1.KongProtocolsToStrings(k8sPlugin.Protocols)...) + } + errText, err := validator.validatePluginAgainstGatewaySchema(ctx, plugin) + if err != nil || errText != "" { + return false, errText, err + } + + return true, "", nil +} + +func (validator KongHTTPValidator) validatePluginAgainstGatewaySchema(ctx context.Context, plugin kong.Plugin) (string, error) { + pluginService, hasClient := validator.AdminAPIServicesProvider.GetPluginsService() + if hasClient { + isValid, msg, err := pluginService.Validate(ctx, &plugin) + if err != nil { + return ErrTextPluginConfigValidationFailed, err + } + if !isValid { + return fmt.Sprintf(ErrTextPluginConfigViolatesSchema, msg), nil + } + } + + // if there's no client, do not verify with data-plane as there's none available + return "", nil +} diff --git a/internal/admission/secret_validator.go b/internal/admission/secret_validator.go new file mode 100644 index 00000000000..9a7a216218d --- /dev/null +++ b/internal/admission/secret_validator.go @@ -0,0 +1,83 @@ +package admission + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + + credsvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/consumers/credentials" +) + +func (validator KongHTTPValidator) Secret() CustomValidatorAdapter { + // secrets are only validated on update because they must be referenced by a + // managed consumer in order for us to validate them, and because credentials + // validation also happens at the consumer side of the reference so a + // credentials secret can not be referenced without being validated. + return CustomValidatorAdapter{ + validateUpdate: func(ctx context.Context, oldObj runtime.Object, newObj runtime.Object) (bool, string, error) { + secret, ok := newObj.(*corev1.Secret) + if !ok { + return false, "", fmt.Errorf("unexpected type, expected *corev1.Secret, got %T", newObj) + } + return validator.ValidateCredential(ctx, *secret) + }, + } +} + +// ValidateCredential checks if the secret contains a credential meant to +// be installed in Kong. If so, then it verifies if all the required fields +// are present in it or not. If valid, it returns true with an empty string, +// else it returns false with the error message. If an error happens during +// validation, error is returned. +func (validator KongHTTPValidator) ValidateCredential( + ctx context.Context, + secret corev1.Secret, +) (bool, string, error) { + // if the secret doesn't contain a type key it's not a credentials secret + _, ok := secret.Data[credsvalidation.TypeKey] + if !ok { + return true, "", nil + } + + // credentials are only validated if they are referenced by a managed consumer + // in the namespace, as such we pull a list of all consumers from the cached + // client to determine if the credentials are referenced. + managedConsumers, err := validator.listManagedConsumers(ctx) + if err != nil { + return false, ErrTextConsumerUnretrievable, err + } + + // verify whether this secret is referenced by any managed consumer + managedConsumersWithReferences := listManagedConsumersReferencingCredentialsSecret(secret, managedConsumers) + if len(managedConsumersWithReferences) == 0 { + // if no managed consumers reference this secret, its considered + // unmanaged and we don't validate it unless it becomes referenced + // by a managed consumer at a later time. + return true, "", nil + } + + // now that we know at least one managed consumer is referencing this + // secret we perform the base-level credentials secret validation. + if err := credsvalidation.ValidateCredentials(&secret); err != nil { + return false, ErrTextConsumerCredentialValidationFailed, err + } + + // if base-level validation passes we move on to create an index of + // all managed credentials so that we can verify that the updates to + // this secret are not in violation of any unique key constraints. + ignoreSecrets := map[string]map[string]struct{}{secret.Namespace: {secret.Name: {}}} + credentialsIndex, err := globalValidationIndexForCredentials(ctx, validator.ManagerClient, managedConsumers, ignoreSecrets) + if err != nil { + return false, ErrTextConsumerCredentialValidationFailed, err + } + + // the index is built, now validate that the newly updated secret + // is not in violation of any constraints. + if err := credentialsIndex.ValidateCredentialsForUniqueKeyConstraints(&secret); err != nil { + return false, ErrTextConsumerCredentialValidationFailed, err + } + + return true, "", nil +} diff --git a/internal/admission/server.go b/internal/admission/server.go index 2063276451d..c7ada71842e 100644 --- a/internal/admission/server.go +++ b/internal/admission/server.go @@ -4,20 +4,11 @@ import ( "context" "crypto/tls" "fmt" - "net/http" - "time" - "github.com/sirupsen/logrus" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/serializer" + "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/certwatcher" ) -var ( - scheme = runtime.NewScheme() - codecs = serializer.NewCodecFactory(scheme) -) - const ( DefaultAdmissionWebhookCertPath = "/admission-webhook/tls.crt" DefaultAdmissionWebhookKeyPath = "/admission-webhook/tls.key" @@ -33,23 +24,9 @@ type ServerConfig struct { Key string } -func MakeTLSServer(ctx context.Context, config *ServerConfig, handler http.Handler, - log logrus.FieldLogger, -) (*http.Server, error) { - const defaultHTTPReadHeaderTimeout = 10 * time.Second - tlsConfig, err := serverConfigToTLSConfig(ctx, config, log) - if err != nil { - return nil, err - } - return &http.Server{ - Addr: config.ListenAddr, - TLSConfig: tlsConfig, - Handler: handler, - ReadHeaderTimeout: defaultHTTPReadHeaderTimeout, - }, nil -} - -func serverConfigToTLSConfig(ctx context.Context, sc *ServerConfig, log logrus.FieldLogger) (*tls.Config, error) { +// ServerConfigToTLSConfig converts a ServerConfig to a tls.Config. +// TODO: this could be handled by controller-runtime if we set its webhook.Options properly. +func ServerConfigToTLSConfig(ctx context.Context, sc *ServerConfig, log logr.Logger) (*tls.Config, error) { var watcher *certwatcher.CertWatcher var cert, key []byte switch { @@ -66,7 +43,7 @@ func serverConfigToTLSConfig(ctx context.Context, sc *ServerConfig, log logrus.F Certificates: []tls.Certificate{keyPair}, }, nil - // the caller provided explicit file paths to the certs, enable certwatcher for these paths + // the caller provided explicit file paths to the certs, enable certwatcher for these paths case sc.CertPath != "" && sc.KeyPath != "" && sc.Cert == "" && sc.Key == "": var err error watcher, err = certwatcher.New(sc.CertPath, sc.KeyPath) @@ -74,7 +51,7 @@ func serverConfigToTLSConfig(ctx context.Context, sc *ServerConfig, log logrus.F return nil, fmt.Errorf("failed to create CertWatcher: %w", err) } - // the caller provided no certificate configuration, assume the default paths and enable certwatcher for them + // the caller provided no certificate configuration, assume the default paths and enable certwatcher for them case sc.CertPath == "" && sc.KeyPath == "" && sc.Cert == "" && sc.Key == "": var err error watcher, err = certwatcher.New(DefaultAdmissionWebhookCertPath, DefaultAdmissionWebhookKeyPath) @@ -88,7 +65,7 @@ func serverConfigToTLSConfig(ctx context.Context, sc *ServerConfig, log logrus.F go func() { if err := watcher.Start(ctx); err != nil { - log.WithError(err).Error("certificate watcher error") + log.Error(err, "certificate watcher error") } }() return &tls.Config{ diff --git a/internal/admission/server_test.go b/internal/admission/server_test.go deleted file mode 100644 index 95f98157352..00000000000 --- a/internal/admission/server_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package admission - -import ( - "bytes" - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/lithammer/dedent" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - admissionv1 "k8s.io/api/admission/v1" - corev1 "k8s.io/api/core/v1" - netv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/kong/kubernetes-ingress-controller/v2/internal/gatewayapi" - kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" - kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1beta1" -) - -var decoder = codecs.UniversalDeserializer() - -type KongFakeValidator struct { - Result bool - Message string - Error error -} - -func (v KongFakeValidator) ValidateConsumer( - _ context.Context, - _ kongv1.KongConsumer, -) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidateConsumerGroup( - _ context.Context, - _ kongv1beta1.KongConsumerGroup, -) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidatePlugin( - _ context.Context, - _ kongv1.KongPlugin, -) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidateClusterPlugin( - _ context.Context, - _ kongv1.KongClusterPlugin, -) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidateCredential(_ context.Context, _ corev1.Secret) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidateGateway(_ context.Context, _ gatewayapi.Gateway) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidateHTTPRoute(_ context.Context, _ gatewayapi.HTTPRoute) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func (v KongFakeValidator) ValidateIngress(_ context.Context, _ netv1.Ingress) (bool, string, error) { - return v.Result, v.Message, v.Error -} - -func TestServeHTTPBasic(t *testing.T) { - assert := assert.New(t) - res := httptest.NewRecorder() - server := RequestHandler{ - Validator: KongFakeValidator{}, - Logger: logrus.New(), - } - handler := http.HandlerFunc(server.ServeHTTP) - - req, err := http.NewRequest("POST", "", nil) - assert.Nil(err) - handler.ServeHTTP(res, req) - assert.Equal(400, res.Code) - assert.Equal("admission review object is missing\n", - res.Body.String()) -} - -func TestValidationWebhook(t *testing.T) { - for _, apiVersion := range []string{ - "admission.k8s.io/v1beta1", - "admission.k8s.io/v1", - } { - for _, tt := range []struct { - name string - reqBody string - validator KongValidator - - wantRespCode int - wantSuccessResponse admissionv1.AdmissionResponse - wantFailureMessage string - }{ - { - name: "request with present empty body", - wantRespCode: http.StatusBadRequest, - wantFailureMessage: "EOF\n", - }, - { - name: "validate kong consumer", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongconsumers" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer" - }, - "operation": "CREATE" - } - }`), - validator: KongFakeValidator{Result: true}, - wantRespCode: http.StatusOK, - wantSuccessResponse: admissionv1.AdmissionResponse{ - UID: "b2df61dd-ab5b-4cb4-9be0-878533c83892", - Allowed: true, - Result: &metav1.Status{}, - }, - }, - { - name: "validate kong consumer on username change", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongconsumers" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer", - "username":"foo" - }, - "oldObject": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer", - "username":"bar" - }, - "operation": "UPDATE" - } - } - `), - validator: KongFakeValidator{Result: true}, - wantRespCode: http.StatusOK, - wantSuccessResponse: admissionv1.AdmissionResponse{ - UID: "b2df61dd-ab5b-4cb4-9be0-878533c83892", - Allowed: true, - Result: &metav1.Status{}, - }, - }, - { - name: "validate kong consumer on equal update", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongconsumers" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer", - "username":"foo" - }, - "oldObject": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer", - "username":"foo" - }, - "operation": "UPDATE" - } - }`), - validator: KongFakeValidator{Result: true}, - wantRespCode: http.StatusOK, - wantSuccessResponse: admissionv1.AdmissionResponse{ - UID: "b2df61dd-ab5b-4cb4-9be0-878533c83892", - Allowed: true, - Result: &metav1.Status{}, - }, - }, - { - name: "validate kong consumer invalid", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongconsumers" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer" - }, - "operation": "CREATE" - } - }`), - validator: KongFakeValidator{Result: false, Message: "consumer is not valid"}, - wantRespCode: http.StatusOK, - wantSuccessResponse: admissionv1.AdmissionResponse{ - UID: "b2df61dd-ab5b-4cb4-9be0-878533c83892", - Allowed: false, - Result: &metav1.Status{ - Code: http.StatusBadRequest, - Message: "consumer is not valid", - }, - }, - }, - { - name: "kong consumer validator error", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongconsumers" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer" - }, - "operation": "CREATE" - } - }`), - validator: KongFakeValidator{Error: errors.New("error making API call to kong")}, - wantRespCode: http.StatusInternalServerError, - wantFailureMessage: "error making API call to kong\n", - }, - { - name: "kong consumer validator error on username change", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongconsumers" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer", - "username":"foo" - }, - "oldObject": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer", - "username":"bar" - }, - "operation": "UPDATE" - } - }`), - validator: KongFakeValidator{Error: errors.New("error making API call to kong")}, - wantRespCode: http.StatusInternalServerError, - wantFailureMessage: "error making API call to kong\n", - }, - { - name: "unknown resource", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongunknown" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongConsumer" - }, - "operation": "CREATE" - } - }`), - validator: KongFakeValidator{Result: false, Message: "consumer is not valid"}, - wantRespCode: http.StatusInternalServerError, - wantFailureMessage: "unknown resource type to validate: configuration.konghq.com/v1 kongunknown\n", - }, - { - name: "validate kong plugin", - reqBody: dedent.Dedent(` - { - "kind": "AdmissionReview", - "apiVersion": "` + apiVersion + `", - "request": { - "uid": "b2df61dd-ab5b-4cb4-9be0-878533c83892", - "resource": { - "group": "configuration.konghq.com", - "version": "v1", - "resource": "kongplugins" - }, - "object": { - "apiVersion": "configuration.konghq.com/v1", - "kind": "KongPlugin" - } - } - }`), - validator: KongFakeValidator{Result: true}, - wantRespCode: http.StatusOK, - wantSuccessResponse: admissionv1.AdmissionResponse{ - UID: "b2df61dd-ab5b-4cb4-9be0-878533c83892", - Allowed: true, - Result: &metav1.Status{}, - }, - }, - } { - tt := tt - t.Run(fmt.Sprintf("%s/%s", apiVersion, tt.name), func(t *testing.T) { - // arrange - assert := assert.New(t) - res := httptest.NewRecorder() - server := RequestHandler{ - Validator: tt.validator, - Logger: logrus.New(), - } - handler := http.HandlerFunc(server.ServeHTTP) - - // act - req, err := http.NewRequest("POST", "", bytes.NewBuffer([]byte(tt.reqBody))) - assert.Nil(err) - handler.ServeHTTP(res, req) - - // assert - assert.Equal(tt.wantRespCode, res.Code) - if tt.wantRespCode == http.StatusOK { - var review admissionv1.AdmissionReview - _, _, err = decoder.Decode(res.Body.Bytes(), nil, &review) - assert.Nil(err) - assert.EqualValues(&tt.wantSuccessResponse, review.Response) - } else { - assert.Equal(res.Body.String(), tt.wantFailureMessage) - } - }) - } - } -} diff --git a/internal/admission/utils.go b/internal/admission/utils.go index ff786131a7a..d94121e727e 100644 --- a/internal/admission/utils.go +++ b/internal/admission/utils.go @@ -1,13 +1,8 @@ package admission import ( - "context" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/client" - credsvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/consumers/credentials" kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" ) @@ -35,47 +30,3 @@ func listManagedConsumersReferencingCredentialsSecret(secret corev1.Secret, mana } return consumersWhichReferenceSecret } - -// globalValidationIndexForCredentials builds an index of all consumer credentials -// using a given controller-runtime client. This provides an index based on -// ALL namespaces in the cluster. This can be very expensive with high numbers -// of consumer credentials, particularly if the client you provide is not cached -// -// if the caller is building the index to validate updates for specific secrets -// and those secrets should be excluded from the index because they will be added -// later, a map of the namespace and name of those secrets can be provided to exclude them. -func globalValidationIndexForCredentials(ctx context.Context, managerClient client.Client, consumers []*kongv1.KongConsumer, ignoredSecrets map[string]map[string]struct{}) (credsvalidation.Index, error) { - // pull the reference secrets for credentials from each consumer in the list - index := make(credsvalidation.Index) - for _, consumer := range consumers { - for _, secretName := range consumer.Credentials { - // if its been requested that this secret be specifically ignored - // (e.g. that secret is being updated and will soon have new values) - // then don't add it to the index. - if secrets, namespaceContainsSkippedSecrets := ignoredSecrets[consumer.Namespace]; namespaceContainsSkippedSecrets { - if _, secretShouldBeSkipped := secrets[secretName]; secretShouldBeSkipped { - continue - } - } - - // grab a copy of the credential secret - secret := &corev1.Secret{} - if err := managerClient.Get(ctx, client.ObjectKey{ - Namespace: consumer.Namespace, - Name: secretName, - }, secret); err != nil { - if apierrors.IsNotFound(err) { // ignore missing secrets - continue - } - return nil, err - } - - // add the credential secret to the index - if err := index.ValidateCredentialsForUniqueKeyConstraints(secret); err != nil { - return nil, err - } - } - } - - return index, nil -} diff --git a/internal/admission/validator.go b/internal/admission/validator.go index 033b28944f5..94fd3f18343 100644 --- a/internal/admission/validator.go +++ b/internal/admission/validator.go @@ -2,27 +2,19 @@ package admission import ( "context" - "fmt" - "strings" "github.com/blang/semver/v4" "github.com/kong/go-kong/kong" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - credsvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/consumers/credentials" - gatewayvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/gateway" - ingressvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/admission/validation/ingress" "github.com/kong/kubernetes-ingress-controller/v2/internal/annotations" - gatewaycontroller "github.com/kong/kubernetes-ingress-controller/v2/internal/controllers/gateway" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/kongstate" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser" "github.com/kong/kubernetes-ingress-controller/v2/internal/gatewayapi" - "github.com/kong/kubernetes-ingress-controller/v2/internal/versions" kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1beta1" ) @@ -88,445 +80,6 @@ func NewKongHTTPValidator( } } -// ValidateConsumer checks if consumer has a Username and a consumer with -// the same username doesn't exist in Kong. -// If an error occurs during validation, it is returned as the last argument. -// The first boolean communicates if the consumer is valid or not and string -// holds a message if the entity is not valid. -func (validator KongHTTPValidator) ValidateConsumer( - ctx context.Context, - consumer kongv1.KongConsumer, -) (bool, string, error) { - // ignore consumers that are being managed by another controller - if !validator.ingressClassMatcher(&consumer.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) { - return true, "", nil - } - - // a consumer without a username is not valid - if consumer.Username == "" { - return false, ErrTextConsumerUsernameEmpty, nil - } - - errText, err := validator.ensureConsumerDoesNotExistInGateway(ctx, consumer.Username) - if err != nil || errText != "" { - return false, errText, err - } - - // if there are no credentials for this consumer, there's no need to move on - // to credentials validation. - if len(consumer.Credentials) == 0 { - return true, "", nil - } - - // pull all the managed consumers in order to build a validation index of - // credentials so that the consumers credentials references can be validated. - managedConsumers, err := validator.listManagedConsumers(ctx) - if err != nil { - return false, ErrTextConsumerUnretrievable, err - } - - // retrieve the consumer's credentials secrets to validate them with the index - credentials := make([]*corev1.Secret, 0, len(consumer.Credentials)) - ignoredSecrets := make(map[string]map[string]struct{}) - for _, secretName := range consumer.Credentials { - // retrieve the credentials secret - secret, err := validator.SecretGetter.GetSecret(consumer.Namespace, secretName) - if err != nil { - if apierrors.IsNotFound(err) { - return false, ErrTextConsumerCredentialSecretNotFound, err - } - return false, ErrTextFailedToRetrieveSecret, err - } - - // do the basic credentials validation - if err := credsvalidation.ValidateCredentials(secret); err != nil { - return false, ErrTextConsumerCredentialValidationFailed, err - } - - // if valid, store it so we can index it for upcoming constraints validation - credentials = append(credentials, secret) - - // later we'll build a global index of all credentials which is needed to - // validate unique key constraints. That index should omit the secrets that - // are referenced by this consumer to avoid duplication. - if _, ok := ignoredSecrets[consumer.Namespace]; !ok { - ignoredSecrets[consumer.Namespace] = make(map[string]struct{}, len(consumer.Credentials)) - } - ignoredSecrets[consumer.Namespace][secretName] = struct{}{} - } - - // unique constraints on consumer credentials are global to all consumers - // and credentials, so we must build an index based on all existing credentials. - // we ignore the secrets referenced by this consumer so that the index is not - // testing them against themselves. - credentialsIndex, err := globalValidationIndexForCredentials(ctx, validator.ManagerClient, managedConsumers, ignoredSecrets) - if err != nil { - return false, ErrTextConsumerCredentialValidationFailed, err - } - - // validate the consumer's credentials against the index of all managed - // credentials to ensure they're not in violation of any unique constraints. - for _, secret := range credentials { - // do the unique constraints validation of the credentials using the credentials index - if err := credentialsIndex.ValidateCredentialsForUniqueKeyConstraints(secret); err != nil { - return false, ErrTextConsumerCredentialValidationFailed, err - } - } - - return true, "", nil -} - -func (validator KongHTTPValidator) ValidateConsumerGroup( - ctx context.Context, - consumerGroup kongv1beta1.KongConsumerGroup, -) (bool, string, error) { - // Ignore ConsumerGroups that are being managed by another controller. - if !validator.ingressClassMatcher(&consumerGroup.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) { - return true, "", nil - } - - // Consumer groups work only for Kong Enterprise >=3.4. - infoSvc, ok := validator.AdminAPIServicesProvider.GetInfoService() - if !ok { - return true, "", nil - } - info, err := infoSvc.Get(ctx) - if err != nil { - validator.Logger.Debugf("failed to fetch Kong info: %v", err) - return false, ErrTextAdminAPIUnavailable, nil - } - version, err := kong.NewVersion(info.Version) - if err != nil { - validator.Logger.Debugf("failed to parse Kong version: %v", err) - } else { - kongVer := semver.Version{Major: version.Major(), Minor: version.Minor()} - if !version.IsKongGatewayEnterprise() || !kongVer.GTE(versions.ConsumerGroupsVersionCutoff) { - return false, ErrTextConsumerGroupUnsupported, nil - } - } - - cgs, ok := validator.AdminAPIServicesProvider.GetConsumerGroupsService() - if !ok { - return true, "", nil - } - // This check forbids consumer group creation if the license is invalid or missing. - // There is no other way to robustly check the validity of a license than actually trying an enterprise feature. - if _, _, err := cgs.List(ctx, &kong.ListOpt{Size: 0}); err != nil { - switch { - case kong.IsNotFoundErr(err): - // This is the case when consumer group is not supported (Kong OSS) and previous version - // check (if !version.IsKongGatewayEnterprise()) has been omitted due to a parsing error. - return false, ErrTextConsumerGroupUnsupported, nil - case kong.IsForbiddenErr(err): - return false, ErrTextConsumerGroupUnlicensed, nil - default: - return false, fmt.Sprintf("%s: %s", ErrTextConsumerGroupUnexpected, err), nil - } - } - return true, "", nil -} - -// ValidateCredential checks if the secret contains a credential meant to -// be installed in Kong. If so, then it verifies if all the required fields -// are present in it or not. If valid, it returns true with an empty string, -// else it returns false with the error message. If an error happens during -// validation, error is returned. -func (validator KongHTTPValidator) ValidateCredential( - ctx context.Context, - secret corev1.Secret, -) (bool, string, error) { - // if the secret doesn't contain a type key it's not a credentials secret - _, ok := secret.Data[credsvalidation.TypeKey] - if !ok { - return true, "", nil - } - - // credentials are only validated if they are referenced by a managed consumer - // in the namespace, as such we pull a list of all consumers from the cached - // client to determine if the credentials are referenced. - managedConsumers, err := validator.listManagedConsumers(ctx) - if err != nil { - return false, ErrTextConsumerUnretrievable, err - } - - // verify whether this secret is referenced by any managed consumer - managedConsumersWithReferences := listManagedConsumersReferencingCredentialsSecret(secret, managedConsumers) - if len(managedConsumersWithReferences) == 0 { - // if no managed consumers reference this secret, its considered - // unmanaged and we don't validate it unless it becomes referenced - // by a managed consumer at a later time. - return true, "", nil - } - - // now that we know at least one managed consumer is referencing this - // secret we perform the base-level credentials secret validation. - if err := credsvalidation.ValidateCredentials(&secret); err != nil { - return false, ErrTextConsumerCredentialValidationFailed, err - } - - // if base-level validation passes we move on to create an index of - // all managed credentials so that we can verify that the updates to - // this secret are not in violation of any unique key constraints. - ignoreSecrets := map[string]map[string]struct{}{secret.Namespace: {secret.Name: {}}} - credentialsIndex, err := globalValidationIndexForCredentials(ctx, validator.ManagerClient, managedConsumers, ignoreSecrets) - if err != nil { - return false, ErrTextConsumerCredentialValidationFailed, err - } - - // the index is built, now validate that the newly updated secret - // is not in violation of any constraints. - if err := credentialsIndex.ValidateCredentialsForUniqueKeyConstraints(&secret); err != nil { - return false, ErrTextConsumerCredentialValidationFailed, err - } - - return true, "", nil -} - -// ValidatePlugin checks if k8sPlugin is valid. It does so by performing -// an HTTP request to Kong's Admin API entity validation endpoints. -// If an error occurs during validation, it is returned as the last argument. -// The first boolean communicates if k8sPluign is valid or not and string -// holds a message if the entity is not valid. -func (validator KongHTTPValidator) ValidatePlugin( - ctx context.Context, - k8sPlugin kongv1.KongPlugin, -) (bool, string, error) { - if k8sPlugin.PluginName == "" { - return false, ErrTextPluginNameEmpty, nil - } - var plugin kong.Plugin - plugin.Name = kong.String(k8sPlugin.PluginName) - var err error - plugin.Config, err = kongstate.RawConfigToConfiguration(k8sPlugin.Config) - if err != nil { - return false, ErrTextPluginConfigInvalid, err - } - if k8sPlugin.ConfigFrom != nil { - if len(plugin.Config) > 0 { - return false, ErrTextPluginUsesBothConfigTypes, nil - } - config, err := kongstate.SecretToConfiguration(validator.SecretGetter, (*k8sPlugin.ConfigFrom).SecretValue, k8sPlugin.Namespace) - if err != nil { - return false, ErrTextPluginSecretConfigUnretrievable, err - } - plugin.Config = config - } - if k8sPlugin.RunOn != "" { - plugin.RunOn = kong.String(k8sPlugin.RunOn) - } - if k8sPlugin.Ordering != nil { - plugin.Ordering = k8sPlugin.Ordering - } - if len(k8sPlugin.Protocols) > 0 { - plugin.Protocols = kong.StringSlice(kongv1.KongProtocolsToStrings(k8sPlugin.Protocols)...) - } - errText, err := validator.validatePluginAgainstGatewaySchema(ctx, plugin) - if err != nil || errText != "" { - return false, errText, err - } - - return true, "", nil -} - -// ValidateClusterPlugin transfers relevant fields from a KongClusterPlugin into a KongPlugin and then returns -// the result of ValidatePlugin for the derived KongPlugin. -func (validator KongHTTPValidator) ValidateClusterPlugin( - ctx context.Context, - k8sPlugin kongv1.KongClusterPlugin, -) (bool, string, error) { - derived := kongv1.KongPlugin{ - TypeMeta: k8sPlugin.TypeMeta, - ObjectMeta: k8sPlugin.ObjectMeta, - ConsumerRef: k8sPlugin.ConsumerRef, - Disabled: k8sPlugin.Disabled, - Config: k8sPlugin.Config, - PluginName: k8sPlugin.PluginName, - RunOn: k8sPlugin.RunOn, - Protocols: k8sPlugin.Protocols, - } - if k8sPlugin.ConfigFrom != nil { - ref := kongv1.ConfigSource{ - SecretValue: kongv1.SecretValueFromSource{ - Secret: k8sPlugin.ConfigFrom.SecretValue.Secret, - Key: k8sPlugin.ConfigFrom.SecretValue.Key, - }, - } - derived.ConfigFrom = &ref - derived.ObjectMeta.Namespace = k8sPlugin.ConfigFrom.SecretValue.Namespace - } else { - derived.ObjectMeta.Namespace = "default" - } - return validator.ValidatePlugin(ctx, derived) -} - -func (validator KongHTTPValidator) ValidateGateway( - ctx context.Context, gateway gatewayapi.Gateway, -) (bool, string, error) { - // check if the gateway declares a gateway class - if gateway.Spec.GatewayClassName == "" { - return true, "", nil - } - - // validate the gatewayclass reference - gwc := gatewayapi.GatewayClass{} - if err := validator.ManagerClient.Get(ctx, client.ObjectKey{Name: string(gateway.Spec.GatewayClassName)}, &gwc); err != nil { - if strings.Contains(err.Error(), "not found") { - return true, "", nil // not managed by this controller - } - return false, ErrTextCantRetrieveGatewayClass, err - } - - // validate whether the gatewayclass is a supported class, if not - // then this gateway belongs to another controller. - if gwc.Spec.ControllerName != gatewaycontroller.GetControllerName() { - return true, "", nil - } - - return true, "", nil -} - -func (validator KongHTTPValidator) ValidateHTTPRoute( - ctx context.Context, httproute gatewayapi.HTTPRoute, -) (bool, string, error) { - // in order to be sure whether or not an HTTPRoute resource is managed by this - // controller we disallow references to Gateway resources that do not exist. - var managedGateways []*gatewayapi.Gateway - for _, parentRef := range httproute.Spec.ParentRefs { - // determine the namespace of the gateway referenced via parentRef. If no - // explicit namespace is provided, assume the namespace of the route. - namespace := httproute.Namespace - if parentRef.Namespace != nil { - namespace = string(*parentRef.Namespace) - } - - // gather the Gateway resource referenced by parentRef and fail validation - // if there is no such Gateway resource. - gateway := gatewayapi.Gateway{} - if err := validator.ManagerClient.Get(ctx, client.ObjectKey{ - Namespace: namespace, - Name: string(parentRef.Name), - }, &gateway); err != nil { - return false, fmt.Sprintf("couldn't retrieve referenced gateway %s/%s", namespace, parentRef.Name), err - } - - // pull the referenced GatewayClass object from the Gateway - gatewayClass := gatewayapi.GatewayClass{} - if err := validator.ManagerClient.Get(ctx, client.ObjectKey{Name: string(gateway.Spec.GatewayClassName)}, &gatewayClass); err != nil { - return false, fmt.Sprintf("couldn't retrieve referenced gatewayclass %s", gateway.Spec.GatewayClassName), err - } - - // determine ultimately whether the Gateway is managed by this controller implementation - if gatewayClass.Spec.ControllerName == gatewaycontroller.GetControllerName() { - managedGateways = append(managedGateways, &gateway) - } - } - - // if there are no managed Gateways this is not a supported HTTPRoute - if len(managedGateways) == 0 { - return true, "", nil - } - - // Now that we know whether or not the HTTPRoute is linked to a managed - // Gateway we can run it through full validation. - var routeValidator routeValidator = noOpRoutesValidator{} - if routesSvc, ok := validator.AdminAPIServicesProvider.GetRoutesService(); ok { - routeValidator = routesSvc - } - return gatewayvalidation.ValidateHTTPRoute( - ctx, routeValidator, validator.ParserFeatures, validator.KongVersion, &httproute, managedGateways..., - ) -} - -func (validator KongHTTPValidator) ValidateIngress( - ctx context.Context, ingress netv1.Ingress, -) (bool, string, error) { - // Ignore Ingresses that are being managed by another controller. - if !validator.ingressClassMatcher(&ingress.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) && - !validator.ingressV1ClassMatcher(&ingress, annotations.ExactClassMatch) { - return true, "", nil - } - - var routeValidator routeValidator = noOpRoutesValidator{} - if routesSvc, ok := validator.AdminAPIServicesProvider.GetRoutesService(); ok { - routeValidator = routesSvc - } - return ingressvalidation.ValidateIngress(ctx, routeValidator, validator.ParserFeatures, validator.KongVersion, &ingress) -} - -type routeValidator interface { - Validate(context.Context, *kong.Route) (bool, string, error) -} - -type noOpRoutesValidator struct{} - -func (noOpRoutesValidator) Validate(_ context.Context, _ *kong.Route) (bool, string, error) { - return true, "", nil -} - -// ----------------------------------------------------------------------------- -// KongHTTPValidator - Private Methods -// ----------------------------------------------------------------------------- - -func (validator KongHTTPValidator) listManagedConsumers(ctx context.Context) ([]*kongv1.KongConsumer, error) { - // gather a list of all consumers from the cached client - consumers := &kongv1.KongConsumerList{} - if err := validator.ManagerClient.List(ctx, consumers, &client.ListOptions{ - Namespace: corev1.NamespaceAll, - }); err != nil { - return nil, err - } - - // reduce the consumer set to consumers managed by this controller - managedConsumers := make([]*kongv1.KongConsumer, 0) - for _, consumer := range consumers.Items { - consumer := consumer - if !validator.ingressClassMatcher(&consumer.ObjectMeta, annotations.IngressClassKey, - annotations.ExactClassMatch) { - // ignore consumers (and subsequently secrets) that are managed by other controllers - continue - } - consumerCopy := consumer - managedConsumers = append(managedConsumers, &consumerCopy) - } - - return managedConsumers, nil -} - -func (validator KongHTTPValidator) ensureConsumerDoesNotExistInGateway(ctx context.Context, username string) (string, error) { - if consumerSvc, hasClient := validator.AdminAPIServicesProvider.GetConsumersService(); hasClient { - // verify that the consumer is not already present in the data-plane - c, err := consumerSvc.Get(ctx, &username) - if err != nil { - if !kong.IsNotFoundErr(err) { - validator.Logger.WithError(err).Error("failed to fetch consumer from kong") - return ErrTextConsumerUnretrievable, err - } - } - if c != nil { - return ErrTextConsumerExists, nil - } - } - - // if there's no client, do not verify existence with data-plane as there's none available - return "", nil -} - -func (validator KongHTTPValidator) validatePluginAgainstGatewaySchema(ctx context.Context, plugin kong.Plugin) (string, error) { - pluginService, hasClient := validator.AdminAPIServicesProvider.GetPluginsService() - if hasClient { - isValid, msg, err := pluginService.Validate(ctx, &plugin) - if err != nil { - return ErrTextPluginConfigValidationFailed, err - } - if !isValid { - return fmt.Sprintf(ErrTextPluginConfigViolatesSchema, msg), nil - } - } - - // if there's no client, do not verify with data-plane as there's none available - return "", nil -} - // ----------------------------------------------------------------------------- // Private - Manager Client Secret Getter // ----------------------------------------------------------------------------- diff --git a/internal/manager/run.go b/internal/manager/run.go index d6fa4063879..4345475e085 100644 --- a/internal/manager/run.go +++ b/internal/manager/run.go @@ -164,7 +164,7 @@ func Run(ctx context.Context, c *Config, diagnostic util.ConfigDumpDiagnostic, d ) setupLog.Info("Starting Admission Server") - if err := setupAdmissionServer(ctx, c, clientsManager, mgr.GetClient(), deprecatedLogger, parserFeatureFlags, kongSemVersion); err != nil { + if err := setupAdmissionServer(c, clientsManager, deprecatedLogger, parserFeatureFlags, kongSemVersion, mgr); err != nil { return err } diff --git a/internal/manager/setup.go b/internal/manager/setup.go index 0cf4ed5e927..a8532de15df 100644 --- a/internal/manager/setup.go +++ b/internal/manager/setup.go @@ -2,9 +2,12 @@ package manager import ( "context" + "crypto/tls" "errors" "fmt" "io" + "strconv" + "strings" "time" "github.com/avast/retry-go/v4" @@ -14,6 +17,7 @@ import ( "github.com/kong/deck/cprint" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" ctrl "sigs.k8s.io/controller-runtime" @@ -29,9 +33,12 @@ import ( "github.com/kong/kubernetes-ingress-controller/v2/internal/clients" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser" + "github.com/kong/kubernetes-ingress-controller/v2/internal/gatewayapi" "github.com/kong/kubernetes-ingress-controller/v2/internal/manager/scheme" "github.com/kong/kubernetes-ingress-controller/v2/internal/util" dataplaneutil "github.com/kong/kubernetes-ingress-controller/v2/internal/util/dataplane" + kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" + kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1beta1" ) // ----------------------------------------------------------------------------- @@ -64,6 +71,34 @@ func SetupLoggers(c *Config, output io.Writer) (logrus.FieldLogger, logr.Logger, return deprecatedLogger, logger, nil } +func webhookOptions(ctx context.Context, c *Config, log logr.Logger) (webhook.Options, error) { + tlsConfig, err := admission.ServerConfigToTLSConfig(ctx, &c.AdmissionServer, log) + if err != nil { + return webhook.Options{}, fmt.Errorf("failed to configure webhook TLS: %w", err) + } + withTLSConfig := func(config *tls.Config) { + *config = *tlsConfig.Clone() + } + + hostAndPort := strings.SplitN(c.AdmissionServer.ListenAddr, ":", 2) + var ( + host string + port int + ) + if len(hostAndPort) == 2 { + host = hostAndPort[0] + port, err = strconv.Atoi(hostAndPort[1]) + if err != nil { + return webhook.Options{}, fmt.Errorf("failed to parse webhook port: %w", err) + } + } + return webhook.Options{ + Host: host, + Port: port, + TLSOpts: []func(*tls.Config){withTLSConfig}, + }, nil +} + func setupControllerOptions(ctx context.Context, logger logr.Logger, c *Config, dbmode string, featureGates map[string]bool) (ctrl.Options, error) { logger.Info("building the manager runtime scheme and loading apis into the scheme") scheme, err := scheme.Get(featureGates) @@ -71,13 +106,18 @@ func setupControllerOptions(ctx context.Context, logger logr.Logger, c *Config, return ctrl.Options{}, err } + webhookOpts, err := webhookOptions(ctx, c, logger) + if err != nil { + return ctrl.Options{}, err + } + // configure the general controller options controllerOpts := ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ BindAddress: c.MetricsAddr, }, - WebhookServer: webhook.NewServer(webhook.Options{Port: 9443}), + WebhookServer: webhook.NewServer(webhookOpts), LeaderElection: leaderElectionEnabled(logger, c, dbmode), LeaderElectionID: c.LeaderElectionID, Cache: cache.Options{ @@ -173,13 +213,12 @@ func setupDataplaneSynchronizer( } func setupAdmissionServer( - ctx context.Context, managerConfig *Config, clientsManager *clients.AdminAPIClientsManager, - managerClient client.Client, deprecatedLogger logrus.FieldLogger, parserFeatures parser.FeatureFlags, kongVersion semver.Version, + mgr ctrl.Manager, ) error { logger := deprecatedLogger.WithField("component", "admission-server") @@ -189,24 +228,70 @@ func setupAdmissionServer( } adminAPIServicesProvider := admission.NewDefaultAdminAPIServicesProvider(clientsManager) - srv, err := admission.MakeTLSServer(ctx, &managerConfig.AdmissionServer, &admission.RequestHandler{ - Validator: admission.NewKongHTTPValidator( - logger, - managerClient, - managerConfig.IngressClassName, - adminAPIServicesProvider, - parserFeatures, - kongVersion, - ), - Logger: logger, - }, logger) - if err != nil { - return err + validator := admission.NewKongHTTPValidator( + logger, + mgr.GetClient(), + managerConfig.IngressClassName, + adminAPIServicesProvider, + parserFeatures, + kongVersion, + ) + + if err := ctrl.NewWebhookManagedBy(mgr). + For(&kongv1.KongConsumer{}). + WithValidator(validator.KongConsumer()). + Complete(); err != nil { + return fmt.Errorf("failed to register KongConsumer validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&kongv1beta1.KongConsumerGroup{}). + WithValidator(validator.KongConsumerGroup()). + Complete(); err != nil { + return fmt.Errorf("failed to register KongConsumerGroup validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&kongv1.KongPlugin{}). + WithValidator(validator.KongPlugin()). + Complete(); err != nil { + return fmt.Errorf("failed to register KongPlugin validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&kongv1.KongClusterPlugin{}). + WithValidator(validator.KongClusterPlugin()). + Complete(); err != nil { + return fmt.Errorf("failed to register KongClusterPlugin validator: %w", err) } - go func() { - err := srv.ListenAndServeTLS("", "") - logger.WithError(err).Error("admission webhook server stopped") - }() + if err := ctrl.NewWebhookManagedBy(mgr). + For(&corev1.Secret{}). + WithValidator(validator.Secret()). + Complete(); err != nil { + return fmt.Errorf("failed to register Secret validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&gatewayapi.Gateway{}). + WithValidator(validator.Gateway()). + Complete(); err != nil { + return fmt.Errorf("failed to register Gateway validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&gatewayapi.HTTPRoute{}). + WithValidator(validator.HTTPRoute()). + Complete(); err != nil { + return fmt.Errorf("failed to register HTTPRoute validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&kongv1.KongIngress{}). + WithValidator(admission.KongIngressValidator{}). + Complete(); err != nil { + return fmt.Errorf("failed to register KongIngress validator: %w", err) + } + if err := ctrl.NewWebhookManagedBy(mgr). + For(&netv1.Ingress{}). + WithValidator(validator.Ingress()). + Complete(); err != nil { + return fmt.Errorf("failed to register Ingress validator: %w", err) + } + return nil } diff --git a/test/envtest/kongingress_webhook_test.go b/test/envtest/kongingress_webhook_test.go new file mode 100644 index 00000000000..d65a2a9ddc0 --- /dev/null +++ b/test/envtest/kongingress_webhook_test.go @@ -0,0 +1,61 @@ +//go:build envtest + +package envtest + +import ( + "context" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/net" + + kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" + "github.com/kong/kubernetes-ingress-controller/v2/pkg/clientset" +) + +func TestKongIngressValidationWebhook(t *testing.T) { + scheme := Scheme(t, WithGatewayAPI) + envcfg := Setup(t, scheme) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + RunManager(ctx, t, envcfg) + + kongClient, err := clientset.NewForConfig(envcfg) + require.NoError(t, err) + + t.Run("when deprecated fields are populated warnings are returned", func(t *testing.T) { + kongIngress := &kongv1.KongIngress{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "kong-ingress-validation-", + }, + // Proxy field is deprecated, expecting warning for it. + Proxy: &kongv1.KongIngressService{ + Protocol: lo.ToPtr("tcp"), + }, + // Route field is deprecated, expecting warning for it. + Route: &kongv1.KongIngressRoute{ + Methods: []*string{lo.ToPtr("POST")}, + }, + } + + result := kongClient.ConfigurationV1().RESTClient().Post(). + Resource("kongingresses"). + Body(kongIngress). + Do(ctx) + + assert.NoError(t, result.Error()) + require.Len(t, result.Warnings(), 2) + expectedWarnings := []string{ + "'route' is DEPRECATED. Use Ingress' annotations instead.", + "'proxy' is DEPRECATED. Use Service's annotations instead.", + } + receivedWarnings := lo.Map(result.Warnings(), func(item net.WarningHeader, index int) string { + return item.Text + }) + assert.ElementsMatch(t, expectedWarnings, receivedWarnings) + }) +}