diff --git a/Custom.mk b/Custom.mk index 78b09b07..e16c2788 100644 --- a/Custom.mk +++ b/Custom.mk @@ -38,9 +38,13 @@ lint: revive $(REVIVE) -config revive.toml -formatter stylish ./... .PHONY: test-e2e # you will need to have a Kind cluster up and running to run this target -test-e2e: +test-e2e: cert-manager go test ./test/e2e/ -v -ginkgo.v +.PHONY: cert-manager +cert-manager: + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.10.1/cert-manager.yaml + .PHONY: goreleaser goreleaser: $(GORELEASER) $(GORELEASER): diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index de6ba875..f51a0f70 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,10 +23,11 @@ should provide the most common extensions that will make development easier. First, spin up an empty [KIND][kind] cluster in your development environment. We recommend always creating a new KIND environment for every project you work -on. +on. Once it is up, you must also install the `cert-manager` toolkit... ```sh $ kind create cluster +$ make cert-manager ``` ### Running on the cluster diff --git a/PROJECT b/PROJECT index a39d89f5..48603b90 100644 --- a/PROJECT +++ b/PROJECT @@ -25,13 +25,17 @@ resources: kind: ExecAccessRequest path: github.com/diranged/oz/api/v1alpha1 version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 - api: crdVersion: v1 namespaced: true controller: true domain: wizardofoz.co group: crds - kind: AccessTemplate + kind: PodAccessTemplate path: github.com/diranged/oz/api/v1alpha1 version: v1alpha1 - api: @@ -40,7 +44,11 @@ resources: controller: true domain: wizardofoz.co group: crds - kind: AccessRequest + kind: PodAccessRequest path: github.com/diranged/oz/api/v1alpha1 version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 version: "3" diff --git a/api/v1alpha1/exec_access_request_test.go b/api/v1alpha1/exec_access_request_test.go new file mode 100644 index 00000000..3ae2687d --- /dev/null +++ b/api/v1alpha1/exec_access_request_test.go @@ -0,0 +1,330 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + admissionv1 "k8s.io/api/admission/v1" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// The ExecAccessRequest tests are primarily testing the behavior of the +// ExecAccessRequest struct - but because some of our tests are testing against +// the real Kubernetes API, they indirectly are also acting as tests of the +// ExecAccessRequestController Reconciler(). +var _ = Describe("ExecAccessRequest", Ordered, func() { + var namespace *corev1.Namespace + var deployment *appsv1.Deployment + var template *ExecAccessTemplate + + // These tests create real ExecAccessRequest{} objects in the cluster and + // validate behavior. This indirectly tests both the reconciler code, AND + // directly is testing the Webhook code. These are functional tests to + // ensure basic functionality. + // + // Explicit test-cases for function logic is handled in the next Context() + // below. + Context("Reconciliation / Webhook Tests", func() { + requestName := "test" + + It("Creation of the ExecAccessRequest to work - Passes Webhook", func() { + request := &ExecAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: requestName, + Namespace: template.Namespace, + }, + Spec: ExecAccessRequestSpec{ + TemplateName: template.Name, + Duration: "1h", + }, + } + err := k8sClient.Create(ctx, request) + Expect(err).To(Not(HaveOccurred())) + }) + + It("ExecAccessRequest becomes ready - Passes Reconciliation", func() { + request := &ExecAccessRequest{} + Eventually(func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: requestName, + Namespace: template.Namespace, + }, request) + Expect(err).To(Not(HaveOccurred())) + if request.GetStatus().IsReady() { + return nil + } + return fmt.Errorf( + "Failed to reconcile resource: %s", + strconv.FormatBool(request.GetStatus().IsReady()), + ) + }, time.Minute, time.Second).Should(HaveOccurred()) + }) + + It("ExecAccessRequest Update - Passes Webhook ValidateUpdate() Call", func() { + // Get the request first + request := &ExecAccessRequest{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: requestName, + Namespace: template.Namespace, + }, request) + Expect(err).To(Not(HaveOccurred())) + + // Update it and push it + request.ObjectMeta.SetAnnotations(map[string]string{"foo": "bar"}) + err = k8sClient.Update(ctx, request) + Expect(err).To(Not(HaveOccurred())) + }) + It("ExecAccessRequest Delete - Passes Webhook ValidateUpdate() Call", func() { + // Get the request first + request := &ExecAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: requestName, + Namespace: template.Namespace, + }, + } + err := k8sClient.Delete(ctx, request) + Expect(err).To(Not(HaveOccurred())) + }) + }) + + // This Context() tests specific functions - no real calls against the API + // are made here. Fake data is passed into the functions to verify that + // they each behave correctly under different conditions. + Context("Functional Unit Tests", func() { + var ( + err error + requestName = "test" + admissionRequest *admission.Request + + request = &ExecAccessRequest{ + Spec: ExecAccessRequestSpec{ + TemplateName: "", + Duration: "", + }, + } + + gvr = metav1.GroupVersionResource{ + Group: api.SchemeGroupVersion.Group, + Version: api.SchemeGroupVersion.Version, + Resource: "execaccessrequest", + } + gvk = metav1.GroupVersionKind{ + Group: api.SchemeGroupVersion.Group, + Version: api.SchemeGroupVersion.Version, + Kind: ExecAccessRequest{}.Kind, + } + ) + + // TODO: The "Userinfo" checks should move into an authentication + // package so that we can write one set of tests for all of the + // Validate* functions. + It("Create with UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "CREATE", + UserInfo: authenticationv1.UserInfo{ + Username: "admin", + UID: "", + Groups: []string{}, + Extra: map[string]authenticationv1.ExtraValue{ + "": {}, + }, + }, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateCreate(*admissionRequest) + Expect(err).To(Not(HaveOccurred())) + }) + + It("Create without UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "CREATE", + UserInfo: authenticationv1.UserInfo{}, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateCreate(*admissionRequest) + Expect(err).To(Not(HaveOccurred())) + }) + + It("Update with UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "UPDATE", + UserInfo: authenticationv1.UserInfo{ + Username: "admin", + UID: "", + Groups: []string{}, + Extra: map[string]authenticationv1.ExtraValue{ + "": {}, + }, + }, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateUpdate(*admissionRequest, request) + Expect(err).To(Not(HaveOccurred())) + }) + + It("Update without UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "UPDATE", + UserInfo: authenticationv1.UserInfo{}, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateUpdate(*admissionRequest, request) + Expect(err).To(Not(HaveOccurred())) + }) + }) + + // Setup code below here - this code rarely changes, the tests above are + // much more important. + BeforeAll(func() { + By("Creating the Namespace to perform the tests") + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: randomString(8), + }, + } + err := k8sClient.Create(ctx, namespace) + Expect(err).To(Not(HaveOccurred())) + + By("Creating the Deployment to peform the tests") + deployment = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: namespace.Name, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "testLabel": "testValue", + }, + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "Foo": "bar", + }, + Labels: map[string]string{ + "testLabel": "testValue", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "conta", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + err = k8sClient.Create(ctx, deployment) + Expect(err).To(Not(HaveOccurred())) + }) + + AfterAll(func() { + By("Deleting the Namespace for tests") + err := k8sClient.Delete(ctx, namespace) + Expect(err).To(Not(HaveOccurred())) + }) + + BeforeEach(func() { + var err error + + // Create the execaccesstemplate + template = &ExecAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: namespace.Name, + }, + Spec: ExecAccessTemplateSpec{ + AccessConfig: AccessConfig{ + AllowedGroups: []string{"admins"}, + DefaultDuration: "1h", + MaxDuration: "24h", + }, + ControllerTargetRef: &CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment.Name, + }, + }, + } + err = k8sClient.Create(ctx, template) + Expect(err).To(Not(HaveOccurred())) + + // Verify the template is ready + Eventually(func() error { + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: template.Name, + Namespace: template.Namespace, + }, template) + Expect(err).To(Not(HaveOccurred())) + + if template.GetStatus().IsReady() { + return nil + } + return fmt.Errorf( + "Failed to reconcile resource: %s", + strconv.FormatBool(template.GetStatus().IsReady()), + ) + }, time.Minute, time.Second).Should(HaveOccurred()) + }) + + AfterEach(func() { + // Clear out the Template after each test + err := k8sClient.Delete(ctx, template) + Expect(err).To(Not(HaveOccurred())) + }) +}) diff --git a/api/v1alpha1/exec_access_request_types.go b/api/v1alpha1/exec_access_request_types.go index e1d56953..0a2f903d 100644 --- a/api/v1alpha1/exec_access_request_types.go +++ b/api/v1alpha1/exec_access_request_types.go @@ -22,7 +22,6 @@ import ( "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -129,20 +128,6 @@ func (r *ExecAccessRequest) GetPodName() string { return r.Status.PodName } -// ValidateUpdate prevents immutable updates to the ExecAccessRequest. -// -// https://stackoverflow.com/questions/70650677/manage-immutable-fields-in-kubebuilder-validating-webhook -// TODO: is this webhook only? -func (r *ExecAccessRequest) ValidateUpdate(old runtime.Object) error { - oldRequest, _ := old.(*ExecAccessRequest) - if r.Spec.TargetPod != oldRequest.Spec.TargetPod { - return fmt.Errorf( - "error - Spec.TargetPod is an immutable field, create a new PodAccessRequest instead", - ) - } - return nil -} - // GetExecAccessRequest returns back an ExecAccessRequest resource matching the request supplied to // the reconciler loop, or returns back an error. func GetExecAccessRequest( diff --git a/api/v1alpha1/exec_access_request_webhook.go b/api/v1alpha1/exec_access_request_webhook.go new file mode 100644 index 00000000..78aadc17 --- /dev/null +++ b/api/v1alpha1/exec_access_request_webhook.go @@ -0,0 +1,100 @@ +/* +Copyright 2022 Matt Wise. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "errors" + "fmt" + + "github.com/diranged/oz/webhook" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var execaccessrequestlog = logf.Log.WithName("execaccessrequest-resource") + +// SetupWebhookWithManager configures the webhook service in the Manager to +// accept MutatingWebhookConfiguration and ValidatingWebhookConfiguration calls +// from the Kubernetes API server. +func (r *ExecAccessRequest) SetupWebhookWithManager(mgr ctrl.Manager) error { + if err := webhook.RegisterContextualDefaulter(r, mgr); err != nil { + panic(err) + } + if err := webhook.RegisterContextualValidator(r, mgr); err != nil { + panic(err) + } + + // boilerplate + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +//+kubebuilder:webhook:path=/mutate-crds-wizardofoz-co-v1alpha1-execaccessrequest,mutating=true,failurePolicy=fail,sideEffects=None,groups=crds.wizardofoz.co,resources=execaccessrequests,verbs=create;update,versions=v1alpha1,name=mexecaccessrequest.kb.io,admissionReviewVersions=v1 + +var _ webhook.IContextuallyDefaultableObject = &ExecAccessRequest{} + +// Default implements webhook.Defaulter so a webhook will be registered for the type +func (r *ExecAccessRequest) Default(req admission.Request) error { + logger := log.FromContext(context.Background()) + logger.Info("defaulter Well gotcha", "req", ObjectToJSON(req), "self", ObjectToJSON(r)) + return errors.New("junk") +} + +//+kubebuilder:webhook:path=/validate-crds-wizardofoz-co-v1alpha1-execaccessrequest,mutating=false,failurePolicy=fail,sideEffects=None,groups=crds.wizardofoz.co,resources=execaccessrequests,verbs=create;update;delete,versions=v1alpha1,name=vexecaccessrequest.kb.io,admissionReviewVersions=v1 + +var _ webhook.IContextuallyValidatableObject = &ExecAccessRequest{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *ExecAccessRequest) ValidateCreate(req admission.Request) error { + if req.UserInfo.Username != "" { + execaccessrequestlog.Info( + fmt.Sprintf("Create ExecAccessRequest from %s", req.UserInfo.Username), + ) + } else { + // TODO: Make this fail, after we have confidence in the code in a live environment. + execaccessrequestlog.Info("WARNING - Create ExecAccessRequest with missing user identity") + } + return nil +} + +// ValidateUpdate prevents immutable updates to the ExecAccessRequest. +func (r *ExecAccessRequest) ValidateUpdate(_ admission.Request, old runtime.Object) error { + execaccessrequestlog.Info("validate update", "name", r.Name) + + // https://stackoverflow.com/questions/70650677/manage-immutable-fields-in-kubebuilder-validating-webhook + oldRequest, _ := old.(*ExecAccessRequest) + if r.Spec.TargetPod != oldRequest.Spec.TargetPod { + return fmt.Errorf( + "error - Spec.TargetPod is an immutable field, create a new PodAccessRequest instead", + ) + } + return nil +} + +// ValidateDelete implements webhook.IContextuallyValidatableObject so a webhook will be registered for the type +func (r *ExecAccessRequest) ValidateDelete(req admission.Request) error { + execaccessrequestlog.Info( + fmt.Sprintf("Delete ExecAccessRequest from %s", req.UserInfo.Username), + ) + return nil +} diff --git a/api/v1alpha1/pod_access_request_test.go b/api/v1alpha1/pod_access_request_test.go new file mode 100644 index 00000000..987066ac --- /dev/null +++ b/api/v1alpha1/pod_access_request_test.go @@ -0,0 +1,331 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + admissionv1 "k8s.io/api/admission/v1" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// The PodAccessRequest tests are primarily testing the behavior of the +// PodAccessRequest struct - but because some of our tests are testing against +// the real Kubernetes API, they indirectly are also acting as tests of the +// PodAccessRequestController Reconciler(). +var _ = Describe("PodAccessRequest", Ordered, func() { + var namespace *corev1.Namespace + var deployment *appsv1.Deployment + var template *PodAccessTemplate + + // These tests create real PodAccessRequest{} objects in the cluster and + // validate behavior. This indirectly tests both the reconciler code, AND + // directly is testing the Webhook code. These are functional tests to + // ensure basic functionality. + // + // Explicit test-cases for function logic is handled in the next Context() + // below. + Context("Reconciliation / Webhook Tests", func() { + requestName := "test" + + It("Creation of the PodAccessRequest to work - Passes Webhook", func() { + request := &PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: requestName, + Namespace: template.Namespace, + }, + Spec: PodAccessRequestSpec{ + TemplateName: template.Name, + Duration: "1h", + }, + } + err := k8sClient.Create(ctx, request) + Expect(err).To(Not(HaveOccurred())) + }) + + It("PodAccessRequest becomes ready - Passes Reconciliation", func() { + request := &PodAccessRequest{} + Eventually(func() error { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: requestName, + Namespace: template.Namespace, + }, request) + Expect(err).To(Not(HaveOccurred())) + if request.GetStatus().IsReady() { + return nil + } + return fmt.Errorf( + "Failed to reconcile resource: %s", + strconv.FormatBool(request.GetStatus().IsReady()), + ) + }, time.Minute, time.Second).Should(HaveOccurred()) + }) + + It("PodAccessRequest Update - Passes Webhook ValidateUpdate() Call", func() { + // Get the request first + request := &PodAccessRequest{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: requestName, + Namespace: template.Namespace, + }, request) + Expect(err).To(Not(HaveOccurred())) + + // Update it and push it + request.ObjectMeta.SetAnnotations(map[string]string{"foo": "bar"}) + err = k8sClient.Update(ctx, request) + Expect(err).To(Not(HaveOccurred())) + }) + It("PodAccessRequest Delete - Passes Webhook ValidateUpdate() Call", func() { + // Get the request first + request := &PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: requestName, + Namespace: template.Namespace, + }, + } + err := k8sClient.Delete(ctx, request) + Expect(err).To(Not(HaveOccurred())) + }) + }) + + // This Context() tests specific functions - no real calls against the API + // are made here. Fake data is passed into the functions to verify that + // they each behave correctly under different conditions. + Context("Functional Unit Tests", func() { + var ( + err error + requestName = "test" + admissionRequest *admission.Request + + request = &PodAccessRequest{ + Spec: PodAccessRequestSpec{ + TemplateName: "", + Duration: "", + }, + } + + gvr = metav1.GroupVersionResource{ + Group: api.SchemeGroupVersion.Group, + Version: api.SchemeGroupVersion.Version, + Resource: "podaccessrequest", + } + gvk = metav1.GroupVersionKind{ + Group: api.SchemeGroupVersion.Group, + Version: api.SchemeGroupVersion.Version, + Kind: PodAccessRequest{}.Kind, + } + ) + + // TODO: The "Userinfo" checks should move into an authentication + // package so that we can write one set of tests for all of the + // Validate* functions. + It("Create with UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "CREATE", + UserInfo: authenticationv1.UserInfo{ + Username: "admin", + UID: "", + Groups: []string{}, + Extra: map[string]authenticationv1.ExtraValue{ + "": {}, + }, + }, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateCreate(*admissionRequest) + Expect(err).To(Not(HaveOccurred())) + }) + + It("Create without UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "CREATE", + UserInfo: authenticationv1.UserInfo{}, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateCreate(*admissionRequest) + Expect(err).To(Not(HaveOccurred())) + }) + + It("Update with UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "UPDATE", + UserInfo: authenticationv1.UserInfo{ + Username: "admin", + UID: "", + Groups: []string{}, + Extra: map[string]authenticationv1.ExtraValue{ + "": {}, + }, + }, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateUpdate(*admissionRequest, request) + Expect(err).To(Not(HaveOccurred())) + }) + + It("Update without UserInfo...", func() { + requestBytes, _ := json.Marshal(request) + admissionRequest = &admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Resource: gvr, + RequestKind: &gvk, + RequestResource: &gvr, + Name: requestName, + Namespace: namespace.Name, + Operation: "UPDATE", + UserInfo: authenticationv1.UserInfo{}, + Object: runtime.RawExtension{ + Raw: requestBytes, + }, + }, + } + err = request.ValidateUpdate(*admissionRequest, request) + Expect(err).To(Not(HaveOccurred())) + }) + }) + + // Setup code below here - this code rarely changes, the tests above are + // much more important. + BeforeAll(func() { + By("Creating the Namespace to perform the tests") + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: randomString(8), + }, + } + err := k8sClient.Create(ctx, namespace) + Expect(err).To(Not(HaveOccurred())) + + By("Creating the Deployment to peform the tests") + deployment = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: namespace.Name, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "testLabel": "testValue", + }, + }, + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "Foo": "bar", + }, + Labels: map[string]string{ + "testLabel": "testValue", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "conta", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + err = k8sClient.Create(ctx, deployment) + Expect(err).To(Not(HaveOccurred())) + }) + + AfterAll(func() { + By("Deleting the Namespace for tests") + err := k8sClient.Delete(ctx, namespace) + Expect(err).To(Not(HaveOccurred())) + }) + + BeforeEach(func() { + var err error + + // Create the podaccesstemplate + template = &PodAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: namespace.Name, + }, + Spec: PodAccessTemplateSpec{ + AccessConfig: AccessConfig{ + AllowedGroups: []string{"admins"}, + DefaultDuration: "1h", + MaxDuration: "24h", + }, + ControllerTargetRef: &CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment.Name, + }, + ControllerTargetMutationConfig: &PodTemplateSpecMutationConfig{}, + }, + } + err = k8sClient.Create(ctx, template) + Expect(err).To(Not(HaveOccurred())) + + // Verify the template is ready + Eventually(func() error { + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: template.Name, + Namespace: template.Namespace, + }, template) + Expect(err).To(Not(HaveOccurred())) + + if template.GetStatus().IsReady() { + return nil + } + return fmt.Errorf( + "Failed to reconcile resource: %s", + strconv.FormatBool(template.GetStatus().IsReady()), + ) + }, time.Minute, time.Second).Should(HaveOccurred()) + }) + + AfterEach(func() { + // Clear out the Template after each test + err := k8sClient.Delete(ctx, template) + Expect(err).To(Not(HaveOccurred())) + }) +}) diff --git a/api/v1alpha1/pod_access_request_webhook.go b/api/v1alpha1/pod_access_request_webhook.go new file mode 100644 index 00000000..cb24b3fb --- /dev/null +++ b/api/v1alpha1/pod_access_request_webhook.go @@ -0,0 +1,94 @@ +/* +Copyright 2022 Matt Wise. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "fmt" + + "github.com/diranged/oz/webhook" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// log is for logging in this package. +var podaccessrequestlog = logf.Log.WithName("podaccessrequest-resource") + +//+kubebuilder:webhook:path=/mutate-crds-wizardofoz-co-v1alpha1-podaccessrequest,mutating=true,failurePolicy=fail,sideEffects=None,groups=crds.wizardofoz.co,resources=podaccessrequests,verbs=create;update,versions=v1alpha1,name=mpodaccessrequest.kb.io,admissionReviewVersions=v1 + +// SetupWebhookWithManager configures the webhook service in the Manager to +// accept MutatingWebhookConfiguration and ValidatingWebhookConfiguration calls +// from the Kubernetes API server. +func (r *PodAccessRequest) SetupWebhookWithManager(mgr ctrl.Manager) error { + if err := webhook.RegisterContextualDefaulter(r, mgr); err != nil { + panic(err) + } + if err := webhook.RegisterContextualValidator(r, mgr); err != nil { + panic(err) + } + + // boilerplate + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +var _ webhook.IContextuallyDefaultableObject = &PodAccessRequest{} + +// Default implements webhook.Defaulter so a webhook will be registered for the type +func (r *PodAccessRequest) Default(_ admission.Request) error { + return nil +} + +//+kubebuilder:webhook:path=/validate-crds-wizardofoz-co-v1alpha1-podaccessrequest,mutating=false,failurePolicy=fail,sideEffects=None,groups=crds.wizardofoz.co,resources=podaccessrequests,verbs=create;update;delete,versions=v1alpha1,name=vpodaccessrequest.kb.io,admissionReviewVersions=v1 + +var _ webhook.IContextuallyValidatableObject = &PodAccessRequest{} + +// ValidateCreate implements webhook.IContextuallyValidatableObject so a webhook will be registered for the type +func (r *PodAccessRequest) ValidateCreate(req admission.Request) error { + if req.UserInfo.Username != "" { + podaccessrequestlog.Info( + fmt.Sprintf("Create PodAccessRequest from %s", req.UserInfo.Username), + ) + } else { + // TODO: Make this fail, after we have confidence in the code in a live environment. + podaccessrequestlog.Info("WARNING - Create ExecAccessRequest with missing user identity") + } + return nil +} + +// ValidateUpdate implements webhook.IContextuallyValidatableObject so a webhook will be registered for the type +func (r *PodAccessRequest) ValidateUpdate(req admission.Request, _ runtime.Object) error { + if req.UserInfo.Username != "" { + podaccessrequestlog.Info( + fmt.Sprintf("Update PodAccessRequest from %s", req.UserInfo.Username), + ) + } else { + // TODO: Make this fail, after we have confidence in the code in a live environment. + podaccessrequestlog.Info("WARNING - Update ExecAccessRequest with missing user identity") + } + return nil +} + +// ValidateDelete implements webhook.IContextuallyValidatableObject so a webhook will be registered for the type +func (r *PodAccessRequest) ValidateDelete(req admission.Request) error { + podaccessrequestlog.Info( + fmt.Sprintf("Delete PodAccessRequest from %s", req.UserInfo.Username), + ) + return nil +} diff --git a/api/v1alpha1/pod_spec_mutation_config_test.go b/api/v1alpha1/pod_spec_mutation_config_test.go index 2eea542d..29a592e7 100644 --- a/api/v1alpha1/pod_spec_mutation_config_test.go +++ b/api/v1alpha1/pod_spec_mutation_config_test.go @@ -1,8 +1,6 @@ package v1alpha1 import ( - "context" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -12,11 +10,10 @@ import ( ) var _ = Describe("PodSpecMutationConfig", Ordered, func() { - ctx := context.Background() var podTemplateSpec corev1.PodTemplateSpec BeforeEach(func() { - // Create a fake deployment target + // Create a fake podTemplateSpec target termPeriod := int64(300) podTemplateSpec = v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ diff --git a/api/v1alpha1/suite_test.go b/api/v1alpha1/suite_test.go new file mode 100644 index 00000000..db7353ae --- /dev/null +++ b/api/v1alpha1/suite_test.go @@ -0,0 +1,145 @@ +package v1alpha1 + +import ( + "context" + "crypto/tls" + "fmt" + "math/rand" + "net" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap/zapcore" + + //+kubebuilder:scaffold:imports + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "API Suite") +} + +var _ = BeforeSuite(func() { + logger := zap.New( + zap.WriteTo(GinkgoWriter), + zap.UseDevMode(true), + zap.Level(zapcore.DebugLevel), + ) + logf.SetLogger(logger) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + // Useful for debugging + // AttachControlPlaneOutput: true, + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{ + filepath.Join("..", "..", "config", "webhook"), + }, + IgnoreErrorIfPathMissing: false, + }, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + LeaderElection: false, + MetricsBindAddress: "0", + }) + Expect(err).NotTo(HaveOccurred()) + + err = (&PodAccessRequest{}).SetupWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&ExecAccessRequest{}).SetupWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf( + "%s:%d", + webhookInstallOptions.LocalServingHost, + webhookInstallOptions.LocalServingPort, + ) + Eventually(func() error { + conn, err := tls.DialWithDialer( + dialer, + "tcp", + addrPort, + &tls.Config{InsecureSkipVerify: true}, + ) + if err != nil { + return err + } + conn.Close() + return nil + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// Utility function for generating a random string for certain tests +func randomString(length int) string { + rand.Seed(time.Now().UnixNano()) + b := make([]byte, length) + rand.Read(b) + return fmt.Sprintf("%x", b)[:length] +} diff --git a/api/v1alpha1/utils.go b/api/v1alpha1/utils.go new file mode 100644 index 00000000..a0a4f4c0 --- /dev/null +++ b/api/v1alpha1/utils.go @@ -0,0 +1,17 @@ +package v1alpha1 + +import ( + "encoding/json" + "fmt" +) + +// ObjectToJSON is a quick helper function for pretty-printing an entire K8S object in JSON form. +// Used in certain debug log statements primarily. +func ObjectToJSON(obj any) string { + jsonData, err := json.Marshal(obj) + if err != nil { + fmt.Printf("could not marshal json: %s\n", err) + return "" + } + return string(jsonData) +} diff --git a/api/v1alpha1/v1alpha1_suite_test.go b/api/v1alpha1/v1alpha1_suite_test.go deleted file mode 100644 index 31fb13ef..00000000 --- a/api/v1alpha1/v1alpha1_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package v1alpha1_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestApi(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Api Suite") -} diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 00000000..bb295946 --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -0,0 +1,39 @@ +# The following manifests contain a self-signed issuer CR and a certificate CR. +# More document can be found at https://docs.cert-manager.io +# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + labels: + app.kubernetes.io/name: certificate + app.kubernetes.io/instance: serving-cert + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: oz + app.kubernetes.io/part-of: oz + app.kubernetes.io/managed-by: kustomize + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + app.kubernetes.io/name: certificate + app.kubernetes.io/instance: serving-cert + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: oz + app.kubernetes.io/part-of: oz + app.kubernetes.io/managed-by: kustomize + name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml + namespace: system +spec: + # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize + dnsNames: + - SERVICE_NAME.SERVICE_NAMESPACE.svc + - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml new file mode 100644 index 00000000..bebea5a5 --- /dev/null +++ b/config/certmanager/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- certificate.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml new file mode 100644 index 00000000..d1d835a0 --- /dev/null +++ b/config/certmanager/kustomizeconfig.yaml @@ -0,0 +1,8 @@ +# This configuration is for teaching kustomize how to update name ref substitution +nameReference: +- kind: Issuer + group: cert-manager.io + fieldSpecs: + - kind: Certificate + group: cert-manager.io + path: spec/issuerRef/name diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index de00241c..0b1d4e37 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -11,18 +11,19 @@ resources: patchesStrategicMerge: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD -#- patches/webhook_in_execaccesstemplates.yaml -#- patches/webhook_in_execaccessrequests.yaml -#- patches/webhook_in_podaccesstemplates.yaml -#- patches/webhook_in_podaccessrequests.yaml +- patches/webhook_in_execaccesstemplates.yaml +- patches/webhook_in_execaccessrequests.yaml +- patches/webhook_in_podaccesstemplates.yaml +- patches/webhook_in_podaccessrequests.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch -# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. -# patches here are for enabling the CA injection for each CRD -#- patches/cainjection_in_execaccesstemplates.yaml -#- patches/cainjection_in_execaccessrequests.yaml -#- patches/cainjection_in_podaccesstemplates.yaml -#- patches/cainjection_in_podaccessrequests.yaml +# [CERTMANAGER] To enable cert-manager, uncomment all the sections with +# [CERTMANAGER] prefix. patches here are for enabling the CA injection for each +# CRD +- patches/cainjection_in_execaccesstemplates.yaml +- patches/cainjection_in_execaccessrequests.yaml +- patches/cainjection_in_podaccesstemplates.yaml +- patches/cainjection_in_podaccessrequests.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_accessrequests.yaml b/config/crd/patches/cainjection_in_podaccessrequests.yaml similarity index 84% rename from config/crd/patches/cainjection_in_accessrequests.yaml rename to config/crd/patches/cainjection_in_podaccessrequests.yaml index e46d748f..dd32514d 100644 --- a/config/crd/patches/cainjection_in_accessrequests.yaml +++ b/config/crd/patches/cainjection_in_podaccessrequests.yaml @@ -4,4 +4,4 @@ kind: CustomResourceDefinition metadata: annotations: cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME - name: accessrequests.crds.wizardofoz.co + name: podaccessrequests.crds.wizardofoz.co diff --git a/config/crd/patches/cainjection_in_accesstemplates.yaml b/config/crd/patches/cainjection_in_podaccesstemplates.yaml similarity index 84% rename from config/crd/patches/cainjection_in_accesstemplates.yaml rename to config/crd/patches/cainjection_in_podaccesstemplates.yaml index eb7ad1a9..99440998 100644 --- a/config/crd/patches/cainjection_in_accesstemplates.yaml +++ b/config/crd/patches/cainjection_in_podaccesstemplates.yaml @@ -4,4 +4,4 @@ kind: CustomResourceDefinition metadata: annotations: cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME - name: accesstemplates.crds.wizardofoz.co + name: podaccesstemplates.crds.wizardofoz.co diff --git a/config/crd/patches/webhook_in_accesstemplates.yaml b/config/crd/patches/webhook_in_podaccessrequests.yaml similarity index 88% rename from config/crd/patches/webhook_in_accesstemplates.yaml rename to config/crd/patches/webhook_in_podaccessrequests.yaml index 61f691b8..9746fdc1 100644 --- a/config/crd/patches/webhook_in_accesstemplates.yaml +++ b/config/crd/patches/webhook_in_podaccessrequests.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: - name: accesstemplates.crds.wizardofoz.co + name: podaccessrequests.crds.wizardofoz.co spec: conversion: strategy: Webhook diff --git a/config/crd/patches/webhook_in_accessrequests.yaml b/config/crd/patches/webhook_in_podaccesstemplates.yaml similarity index 88% rename from config/crd/patches/webhook_in_accessrequests.yaml rename to config/crd/patches/webhook_in_podaccesstemplates.yaml index b3c1df96..98c367e6 100644 --- a/config/crd/patches/webhook_in_accessrequests.yaml +++ b/config/crd/patches/webhook_in_podaccesstemplates.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: - name: accessrequests.crds.wizardofoz.co + name: podaccesstemplates.crds.wizardofoz.co spec: conversion: strategy: Webhook diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 3e785e85..50103fe5 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -18,12 +18,17 @@ resources: - ../crd - ../rbac - ../manager -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- ../webhook -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager -# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix +# including the one in crd/kustomization.yaml +- ../webhook + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with +# 'CERTMANAGER'. 'WEBHOOK' components are required. +- ../certmanager + +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with +# 'PROMETHEUS'. #- ../prometheus patchesStrategicMerge: @@ -32,113 +37,113 @@ patchesStrategicMerge: # endpoint w/o any authn/z, please comment the following line. - manager_auth_proxy_patch.yaml - - -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- manager_webhook_patch.yaml +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix +# including the one in crd/kustomization.yaml +- manager_webhook_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. -# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection -#- webhookcainjection_patch.yaml +# +# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA +# injection in the admission webhooks. 'CERTMANAGER' needs to be enabled to use +# ca injection +- webhookcainjection_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. # Uncomment the following replacements to add the cert-manager CA injection annotations -#replacements: -# - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldPath: .metadata.namespace # namespace of the certificate CR -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - select: -# kind: CustomResourceDefinition -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldPath: .metadata.name -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - select: -# kind: CustomResourceDefinition -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - source: # Add cert-manager annotation to the webhook Service -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.name # namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 0 -# create: true -# - source: -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.namespace # namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 1 -# create: true +replacements: + - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldPath: .metadata.namespace # namespace of the certificate CR + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - select: + kind: CustomResourceDefinition + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 0 + create: true + - source: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldPath: .metadata.name + targets: + - select: + kind: ValidatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - select: + kind: MutatingWebhookConfiguration + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - select: + kind: CustomResourceDefinition + fieldPaths: + - .metadata.annotations.[cert-manager.io/inject-ca-from] + options: + delimiter: '/' + index: 1 + create: true + - source: # Add cert-manager annotation to the webhook Service + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.name # namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 0 + create: true + - source: + kind: Service + version: v1 + name: webhook-service + fieldPath: .metadata.namespace # namespace of the service + targets: + - select: + kind: Certificate + group: cert-manager.io + version: v1 + fieldPaths: + - .spec.dnsNames.0 + - .spec.dnsNames.1 + options: + delimiter: '.' + index: 1 + create: true diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml new file mode 100644 index 00000000..738de350 --- /dev/null +++ b/config/default/manager_webhook_patch.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml new file mode 100644 index 00000000..4900a2c8 --- /dev/null +++ b/config/default/webhookcainjection_patch.yaml @@ -0,0 +1,29 @@ +# This patch add annotation to admission webhook config and +# CERTIFICATE_NAMESPACE and CERTIFICATE_NAME will be substituted by kustomize +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: mutatingwebhookconfiguration + app.kubernetes.io/instance: mutating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: oz + app.kubernetes.io/part-of: oz + app.kubernetes.io/managed-by: kustomize + name: mutating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: validatingwebhookconfiguration + app.kubernetes.io/instance: validating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: oz + app.kubernetes.io/part-of: oz + app.kubernetes.io/managed-by: kustomize + name: validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml new file mode 100644 index 00000000..9cf26134 --- /dev/null +++ b/config/webhook/kustomization.yaml @@ -0,0 +1,6 @@ +resources: +- manifests.yaml +- service.yaml + +configurations: +- kustomizeconfig.yaml diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml new file mode 100644 index 00000000..206316e5 --- /dev/null +++ b/config/webhook/kustomizeconfig.yaml @@ -0,0 +1,22 @@ +# the following config is for teaching kustomize where to look at when substituting nameReference. +# It requires kustomize v2.1.0 or newer to work properly. +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + - kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/name + +namespace: +- kind: MutatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true +- kind: ValidatingWebhookConfiguration + group: admissionregistration.k8s.io + path: webhooks/clientConfig/service/namespace + create: true diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml new file mode 100644 index 00000000..a6fe5da7 --- /dev/null +++ b/config/webhook/manifests.yaml @@ -0,0 +1,96 @@ +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + creationTimestamp: null + name: mutating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-crds-wizardofoz-co-v1alpha1-execaccessrequest + failurePolicy: Fail + name: mexecaccessrequest.kb.io + rules: + - apiGroups: + - crds.wizardofoz.co + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - execaccessrequests + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-crds-wizardofoz-co-v1alpha1-podaccessrequest + failurePolicy: Fail + name: mpodaccessrequest.kb.io + rules: + - apiGroups: + - crds.wizardofoz.co + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - podaccessrequests + sideEffects: None +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + creationTimestamp: null + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-crds-wizardofoz-co-v1alpha1-execaccessrequest + failurePolicy: Fail + name: vexecaccessrequest.kb.io + rules: + - apiGroups: + - crds.wizardofoz.co + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - execaccessrequests + sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-crds-wizardofoz-co-v1alpha1-podaccessrequest + failurePolicy: Fail + name: vpodaccessrequest.kb.io + rules: + - apiGroups: + - crds.wizardofoz.co + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - podaccessrequests + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml new file mode 100644 index 00000000..36934f86 --- /dev/null +++ b/config/webhook/service.yaml @@ -0,0 +1,20 @@ + +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: service + app.kubernetes.io/instance: webhook-service + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: oz + app.kubernetes.io/part-of: oz + app.kubernetes.io/managed-by: kustomize + name: webhook-service + namespace: system +spec: + ports: + - port: 443 + protocol: TCP + targetPort: 9443 + selector: + control-plane: controller-manager diff --git a/controllers/pod_access_template_controller_test.go b/controllers/pod_access_template_controller_test.go index 8acaaa6d..da68db45 100644 --- a/controllers/pod_access_template_controller_test.go +++ b/controllers/pod_access_template_controller_test.go @@ -218,10 +218,8 @@ var _ = Describe("PodAccessTemplateController", Ordered, func() { return nil } return fmt.Errorf( - fmt.Sprintf( - "Failed to reconcile resource: %s", - strconv.FormatBool(found.GetStatus().IsReady()), - ), + "Failed to reconcile resource: %s", + strconv.FormatBool(found.GetStatus().IsReady()), ) }, 10*time.Second, time.Second).Should(Succeed()) }) diff --git a/main.go b/main.go index e149ed49..9cf03086 100644 --- a/main.go +++ b/main.go @@ -30,9 +30,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" + zaplogfmt "github.com/jsternberg/zap-logfmt" + crdsv1alpha1 "github.com/diranged/oz/api/v1alpha1" "github.com/diranged/oz/controllers" - zaplogfmt "github.com/jsternberg/zap-logfmt" //+kubebuilder:scaffold:imports ) @@ -55,6 +56,7 @@ func init() { //+kubebuilder:scaffold:scheme } +// revive:disable:cyclomatic Long, but easy to understand func main() { var metricsAddr string var probeAddr string @@ -180,6 +182,17 @@ func main() { setupLog.Error(err, unableToCreateMsg, controllerKey, "AccessRequest") os.Exit(1) } + + // Webhooks + if err = (&crdsv1alpha1.PodAccessRequest{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "PodAccessRequest") + os.Exit(1) + } + if err = (&crdsv1alpha1.ExecAccessRequest{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "ExecAccessRequest") + os.Exit(1) + } + //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/webhook/contextual_defaulter.go b/webhook/contextual_defaulter.go new file mode 100644 index 00000000..c72d93e7 --- /dev/null +++ b/webhook/contextual_defaulter.go @@ -0,0 +1,118 @@ +package webhook + +import ( + "context" + "encoding/json" + "net/http" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// IContextuallyDefaultableObject implements a similar pattern to the +// [`controller-runtime`](https://github.com/kubernetes-sigs/controller-runtime/tree/v0.13.1/pkg/webhook) +// webhook pattern. The difference is that the `Default()` function is not only +// supplied the request resource, but also the request context in the form of +// an +// [`admission.Request`](https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/webhook.go#L43-L66) +// object. +// +// Modified from https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter_custom.go#L29-L32 +type IContextuallyDefaultableObject interface { + runtime.Object + Default(req admission.Request) error +} + +// RegisterContextualDefaulter leverages many of the patterns and code from the +// Controller-Runtime Admission package, but is one level _less_ abstracted. +// Rather than calling the `Default()` function on the target resource type, +func RegisterContextualDefaulter( + obj IContextuallyDefaultableObject, + mgr ctrl.Manager, +) error { + // Get the GroupVersionKind for the target schema object. + gvk, err := apiutil.GVKForObject(obj, mgr.GetScheme()) + if err != nil { + return err + } + path := generateMutatePath(gvk) + + // Create a Webhook{} resource with our Handler. + mwh := &admission.Webhook{ + Handler: &defaulterForType{object: obj}, + } + + // Insert the path into the webhook server and point it at our mutating + // webhook handler. This must take place before the default controller + // NewWebhookManagedBy().Complete() function is called. + mgr.GetWebhookServer().Register(path, mwh) + + return nil +} + +// A defaulterForType mimics the +// [`defaulterForType`](https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter_custom.go) +// code, but understands to pass the `admission.Request` object into the `Default()` function. +// +// https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter_custom.go#L41-L45 +type defaulterForType struct { + object IContextuallyDefaultableObject + decoder *admission.Decoder +} + +// InjectDecoder injects the decoder into a mutatingHandler. +// +// https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/inject.go +func (h *defaulterForType) InjectDecoder(d *admission.Decoder) error { + h.decoder = d + return nil +} + +var _ admission.DecoderInjector = &defaulterForType{} + +// Handle manages the inbound request from the API server. It's responsible for +// decoding the request into an +// [`admission.Request`](https://pkg.go.dev/k8s.io/api/admission/v1#AdmissionRequest) +// object, calling the `Default()` function on that object, and then returning +// back the patched response to the API server. +func (h *defaulterForType) Handle(_ context.Context, req admission.Request) admission.Response { + // https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter.go#L57-L59 + if h.object == nil { + panic("object should never be nil") + } + + // always skip when a DELETE operation received in mutation handler + // describe in https://github.com/kubernetes-sigs/controller-runtime/issues/1762 + // https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter.go#L61-L70 + if req.Operation == admissionv1.Delete { + return admission.Response{AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: true, + Result: &metav1.Status{ + Code: http.StatusOK, + }, + }} + } + + // Get the object in the request + // https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter.go#L72-L76 + obj := h.object.DeepCopyObject().(IContextuallyDefaultableObject) + if err := h.decoder.Decode(req, obj); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + // Default the object + // + // orig: https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter.go#L78-L83 + obj.Default(req) + marshalled, err := json.Marshal(obj) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + // Create the patch + return admission.PatchResponseFromRaw(req.Object.Raw, marshalled) +} diff --git a/webhook/contextual_defaulter_test.go b/webhook/contextual_defaulter_test.go new file mode 100644 index 00000000..fc6188fc --- /dev/null +++ b/webhook/contextual_defaulter_test.go @@ -0,0 +1,134 @@ +package webhook + +import ( + "context" + "errors" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ = Describe("Defaulter Handler", func() { + It("should return mutated object with username in create", func() { + obj := &TestDefaulter{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &defaulterForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + UserInfo: authenticationv1.UserInfo{ + Username: "foo-user", + }, + Object: runtime.RawExtension{ + Raw: []byte("{}"), + }, + }, + }) + Expect(len(resp.Patches)).To(Equal(1)) + Expect( + string(resp.Patch), + ).To(Equal("[{\"op\":\"add\",\"path\":\"/requestor\",\"value\":\"foo-user\"}]")) + + Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK))) + Expect(resp.Allowed).Should(BeTrue()) + }) + + It("should return ok if received delete verb in defaulter handler", func() { + obj := &TestDefaulter{} + handler := &admission.Webhook{ + Handler: &defaulterForType{object: obj}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte("{}"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK))) + Expect(resp.Allowed).Should(BeTrue()) + }) + It("should fail if decode() fails", func() { + obj := &TestDefaulter{} + handler := &admission.Webhook{ + Handler: &defaulterForType{object: obj}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + OldObject: runtime.RawExtension{ + Raw: []byte("junk"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusBadRequest))) + }) + + It("should panic if no object passed in", func() { + handler := &admission.Webhook{ + Handler: &defaulterForType{object: nil}, + } + Expect(func() { + handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + }, + }) + }).To(Panic()) + }) +}) + +// TestDefaulter. +var _ IContextuallyDefaultableObject = &TestDefaulter{} + +type TestDefaulter struct { + Requestor string `json:"requestor,omitempty"` +} + +var testDefaulterGVK = schema.GroupVersionKind{ + Group: "foo.test.org", + Version: "v1", + Kind: "TestDefaulter", +} + +func (d *TestDefaulter) GetObjectKind() schema.ObjectKind { return d } +func (d *TestDefaulter) DeepCopyObject() runtime.Object { + return &TestDefaulter{ + Requestor: d.Requestor, + } +} + +func (d *TestDefaulter) GroupVersionKind() schema.GroupVersionKind { + return testDefaulterGVK +} + +func (d *TestDefaulter) SetGroupVersionKind(_ schema.GroupVersionKind) {} + +var _ runtime.Object = &TestDefaulterList{} + +type TestDefaulterList struct{} + +func (*TestDefaulterList) GetObjectKind() schema.ObjectKind { return nil } +func (*TestDefaulterList) DeepCopyObject() runtime.Object { return nil } + +func (d *TestDefaulter) Default(req admission.Request) error { + if req.UserInfo.Username != "" { + d.Requestor = req.UserInfo.Username + return nil + } + + return errors.New("must have userinfo context") +} diff --git a/webhook/contextual_validator.go b/webhook/contextual_validator.go new file mode 100644 index 00000000..395c29b9 --- /dev/null +++ b/webhook/contextual_validator.go @@ -0,0 +1,153 @@ +package webhook + +import ( + "context" + "errors" + "net/http" + + v1 "k8s.io/api/admission/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// IContextuallyValidatableObject implements a similar pattern to the +// [`controller-runtime`](https://github.com/kubernetes-sigs/controller-runtime/tree/v0.13.1/pkg/webhook) +// webhook pattern. The difference is that the `Default()` function is not only +// supplied the request resource, but also the request context in the form of +// an +// [`admission.Request`](https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/webhook.go#L43-L66) +// object. +// +// Modified from https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter_custom.go#L29-L32 +type IContextuallyValidatableObject interface { + runtime.Object + ValidateCreate(req admission.Request) error + ValidateUpdate(req admission.Request, old runtime.Object) error + ValidateDelete(req admission.Request) error +} + +// RegisterContextualValidator leverages many of the patterns and code from the +// Controller-Runtime Admission package, but is one level _less_ abstracted. +// Rather than calling the `Default()` function on the target resource type, +func RegisterContextualValidator( + obj IContextuallyValidatableObject, + mgr ctrl.Manager, +) error { + // Get the GroupVersionKind for the target schema object. + gvk, err := apiutil.GVKForObject(obj, mgr.GetScheme()) + if err != nil { + return err + } + path := generateValidatePath(gvk) + + // Create a Webhook{} resource with our Handler. + mwh := &admission.Webhook{ + Handler: &validatorForType{object: obj}, + } + + // Insert the path into the webhook server and point it at our mutating + // webhook handler. This must take place before the default controller + // NewWebhookManagedBy().Complete() function is called. + mgr.GetWebhookServer().Register(path, mwh) + + return nil +} + +// A validatorForType mimics the +// [`validatorForType`](https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter_custom.go) +// code, but understands to pass the `admission.Request` object into the `Default()` function. +// +// https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/defaulter_custom.go#L41-L45 +type validatorForType struct { + object IContextuallyValidatableObject + decoder *admission.Decoder +} + +// InjectDecoder injects the decoder into a mutatingHandler. +// +// https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/inject.go +func (h *validatorForType) InjectDecoder(d *admission.Decoder) error { + h.decoder = d + return nil +} + +var _ admission.DecoderInjector = &validatorForType{} + +// Handle manages the inbound request from the API server. It's responsible for +// decoding the request into an +// [`admission.Request`](https://pkg.go.dev/k8s.io/api/admission/v1#AdmissionRequest) +// object, calling the `Default()` function on that object, and then returning +// back the patched response to the API server. +// Handle handles admission requests. +// +// revive:disable:cyclomatic Replication of existing code in Controller-Runtime +func (h *validatorForType) Handle(_ context.Context, req admission.Request) admission.Response { + // https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/validator.go#L59-L62 + if h.object == nil { + panic("object should never be nil") + } + + // Get the object in the request + // + // https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/validator.go#L63-L79 + obj := h.object.DeepCopyObject().(IContextuallyValidatableObject) + if req.Operation == v1.Create { + err := h.decoder.Decode(req, obj) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + err = obj.ValidateCreate(req) + if err != nil { + var apiStatus apierrors.APIStatus + if errors.As(err, &apiStatus) { + return validationResponseFromStatus(false, apiStatus.Status()) + } + return admission.Denied(err.Error()) + } + } + if req.Operation == v1.Update { + oldObj := obj.DeepCopyObject() + + err := h.decoder.DecodeRaw(req.Object, obj) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + err = h.decoder.DecodeRaw(req.OldObject, oldObj) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + err = obj.ValidateUpdate(req, oldObj) + if err != nil { + var apiStatus apierrors.APIStatus + if errors.As(err, &apiStatus) { + return validationResponseFromStatus(false, apiStatus.Status()) + } + return admission.Denied(err.Error()) + } + } + + if req.Operation == v1.Delete { + // In reference to PR: https://github.com/kubernetes/kubernetes/pull/76346 + // OldObject contains the object being deleted + err := h.decoder.DecodeRaw(req.OldObject, obj) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + err = obj.ValidateDelete(req) + if err != nil { + var apiStatus apierrors.APIStatus + if errors.As(err, &apiStatus) { + return validationResponseFromStatus(false, apiStatus.Status()) + } + return admission.Denied(err.Error()) + } + } + + return admission.Allowed("") +} diff --git a/webhook/contextual_validator_test.go b/webhook/contextual_validator_test.go new file mode 100644 index 00000000..1064a70f --- /dev/null +++ b/webhook/contextual_validator_test.go @@ -0,0 +1,291 @@ +package webhook + +import ( + "context" + "errors" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var _ = Describe("Validator Handler", func() { + It("validateCreate with username matching request should succeed", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + UserInfo: authenticationv1.UserInfo{ + Username: "foo-user", + }, + Object: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"foo-user\"}"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK))) + Expect(resp.Allowed).Should(BeTrue()) + }) + It("validateCreate with non-matching request should fail", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + UserInfo: authenticationv1.UserInfo{ + Username: "user", + }, + Object: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"foo-user\"}"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusForbidden))) + Expect(resp.Allowed).Should(BeFalse()) + }) + + It("validateUpdate with username matching request should succeed", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + UserInfo: authenticationv1.UserInfo{ + Username: "foo-user", + }, + Object: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"foo-user\"}"), + }, + OldObject: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"foo-user\"}"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK))) + Expect(resp.Allowed).Should(BeTrue()) + }) + It("validateUpdate with non-matching request should fail", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + UserInfo: authenticationv1.UserInfo{}, + Object: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"foo-user\"}"), + }, + OldObject: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"bar-user\"}"), + }, + }, + }) + Expect(resp.Result.Code).To(Equal(int32(http.StatusForbidden))) + Expect(resp.Allowed).To(BeFalse()) + }) + It("validateUpdate with invalid object should fail", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + Object: runtime.RawExtension{ + Raw: []byte("junk"), + }, + OldObject: runtime.RawExtension{ + Raw: []byte("junk"), + }, + }, + }) + Expect(resp.Result.Code).To(Equal(int32(http.StatusBadRequest))) + Expect(resp.Allowed).To(BeFalse()) + }) + It("validateUpdate with invalid oldObject should fail", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + Object: runtime.RawExtension{ + Raw: []byte("{}"), + }, + OldObject: runtime.RawExtension{ + Raw: []byte("junk"), + }, + }, + }) + Expect(resp.Result.Code).To(Equal(int32(http.StatusBadRequest))) + Expect(resp.Allowed).To(BeFalse()) + }) + + It("validateDelete with username should succeed", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte("{\"requestor\": \"foo-user\"}"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusOK))) + Expect(resp.Allowed).Should(BeTrue()) + }) + It("validateDelete without username should fail", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte("{\"requestor\":\"\"}"), + }, + }, + }) + Expect(resp.Result.Code).To(Equal(int32(http.StatusForbidden))) + Expect(resp.Allowed).To(BeFalse()) + }) + + It("validateDelete with invalid oldObject should fail", func() { + obj := &TestValidator{} + decoder, _ := admission.NewDecoder(scheme.Scheme) + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj, decoder: decoder}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte("junk"), + }, + }, + }) + Expect(resp.Result.Code).To(Equal(int32(http.StatusBadRequest))) + Expect(resp.Allowed).To(BeFalse()) + }) + + It("should fail if decode() fails", func() { + obj := &TestValidator{} + handler := &admission.Webhook{ + Handler: &validatorForType{object: obj}, + } + + resp := handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + OldObject: runtime.RawExtension{ + Raw: []byte("junk"), + }, + }, + }) + Expect(resp.Result.Code).Should(Equal(int32(http.StatusBadRequest))) + }) + + It("should panic if no object passed in", func() { + handler := &admission.Webhook{ + Handler: &validatorForType{object: nil}, + } + Expect(func() { + handler.Handle(context.TODO(), admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + }, + }) + }).To(Panic()) + }) +}) + +// TestDefaulter. +var _ IContextuallyValidatableObject = &TestValidator{} + +type TestValidator struct { + Requestor string `json:"requestor,omitempty"` +} + +var testValidatorGVK = schema.GroupVersionKind{ + Group: "foo.test.org", + Version: "v1", + Kind: "TestValidator", +} + +func (d *TestValidator) GetObjectKind() schema.ObjectKind { return d } +func (d *TestValidator) DeepCopyObject() runtime.Object { + return &TestValidator{ + Requestor: d.Requestor, + } +} + +func (d *TestValidator) GroupVersionKind() schema.GroupVersionKind { + return testValidatorGVK +} + +func (d *TestValidator) SetGroupVersionKind(_ schema.GroupVersionKind) {} + +var _ runtime.Object = &TestValidatorList{} + +type TestValidatorList struct{} + +func (*TestValidatorList) GetObjectKind() schema.ObjectKind { return nil } +func (*TestValidatorList) DeepCopyObject() runtime.Object { return nil } + +func (d *TestValidator) ValidateCreate(req admission.Request) error { + if d.Requestor != req.UserInfo.DeepCopy().Username { + return errors.New("must have userinfo context") + } + return nil +} + +func (d *TestValidator) ValidateDelete(_ admission.Request) error { + if d.Requestor == "" { + return errors.New("cannot delete") + } + return nil +} + +func (d *TestValidator) ValidateUpdate(_ admission.Request, oldObj runtime.Object) error { + old := oldObj.(*TestValidator) + if d.Requestor != old.Requestor { + return errors.New("requestor field immutable") + } + return nil +} diff --git a/webhook/doc.go b/webhook/doc.go new file mode 100644 index 00000000..5ce62a69 --- /dev/null +++ b/webhook/doc.go @@ -0,0 +1,8 @@ +// Package webhook provides a version of the controller-runtime +// [webhook](https://github.com/kubernetes-sigs/controller-runtime/tree/master/pkg/webhook) +// package. This version passes the +// [`admission.Request`](https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/webhook/admission/webhook.go#L48-L50) +// object into the `Default()`, `ValidateCreate()`, `ValidateUpdate()` and +// `ValidateDelete()` functions to provide more context to these functions for +// making their decisions. +package webhook diff --git a/webhook/suite_test.go b/webhook/suite_test.go new file mode 100644 index 00000000..0ea9c62a --- /dev/null +++ b/webhook/suite_test.go @@ -0,0 +1,25 @@ +package webhook + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap/zapcore" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestWebhookControllers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logger := zap.New( + zap.WriteTo(GinkgoWriter), + zap.UseDevMode(true), + zap.Level(zapcore.DebugLevel), + ) + logf.SetLogger(logger) +}) diff --git a/webhook/utils.go b/webhook/utils.go new file mode 100644 index 00000000..97ef2794 --- /dev/null +++ b/webhook/utils.go @@ -0,0 +1,37 @@ +package webhook + +import ( + "strings" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const delimiter = "-" + +// Copy-Pasta from https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/builder/webhook.go#L208-L216 +func generateMutatePath(gvk schema.GroupVersionKind) string { + return "/mutate" + delimiter + strings.ReplaceAll(gvk.Group, ".", delimiter) + delimiter + + gvk.Version + delimiter + strings.ToLower(gvk.Kind) +} + +// Copy-Pasta from https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/builder/webhook.go#L208-L216 +func generateValidatePath(gvk schema.GroupVersionKind) string { + return "/validate" + delimiter + strings.ReplaceAll(gvk.Group, ".", delimiter) + delimiter + + gvk.Version + delimiter + strings.ToLower(gvk.Kind) +} + +// validationResponseFromStatus returns a response for admitting a request with provided Status object. +// +// https://github.com/kubernetes-sigs/controller-runtime/blob/v0.13.1/pkg/webhook/admission/response.go#L105-L114 +func validationResponseFromStatus(allowed bool, status metav1.Status) admission.Response { + resp := admission.Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: allowed, + Result: &status, + }, + } + return resp +} diff --git a/webhook/utils_test.go b/webhook/utils_test.go new file mode 100644 index 00000000..5f97f948 --- /dev/null +++ b/webhook/utils_test.go @@ -0,0 +1,35 @@ +package webhook + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var _ = Describe("Webhook", Ordered, func() { + Context("Utils", func() { + It("generateMutatePath()/generateValidatePath()", func() { + gvk := schema.GroupVersionKind{ + Group: "testGroup.io", + Version: "v1alpha1", + Kind: "testKind", + } + Expect(generateMutatePath(gvk)).To(Equal("/mutate-testGroup-io-v1alpha1-testkind")) + Expect(generateValidatePath(gvk)).To(Equal("/validate-testGroup-io-v1alpha1-testkind")) + }) + + It("validationResponseFromStatus()", func() { + ret := validationResponseFromStatus(true, metav1.Status{ + Status: "Status", + Message: "Message", + Reason: "Reason", + Code: 200, + }) + Expect(ret.Result.Status).To(Equal("Status")) + Expect(ret.Result.Code).To(Equal(int32(200))) + Expect(ret.Allowed).To(BeTrue()) + }) + }) +})