diff --git a/deploy/hub/kustomization.yaml b/deploy/hub/kustomization.yaml index c0f0fb185..2aaf3f4d1 100644 --- a/deploy/hub/kustomization.yaml +++ b/deploy/hub/kustomization.yaml @@ -28,6 +28,7 @@ resources: - ./managedcluster.crd.yaml - ./manifestwork.crd.yaml - ./managedclusterset.crd.yaml +- ./managedclustersetbinding.crd.yaml - ./namespace.yaml - ./service_account.yaml - ./hub_controller_clusterrole_binding.yaml diff --git a/deploy/hub/managedclustersetbinding.crd.yaml b/deploy/hub/managedclustersetbinding.crd.yaml new file mode 100644 index 000000000..374bfc898 --- /dev/null +++ b/deploy/hub/managedclustersetbinding.crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + creationTimestamp: null + name: managedclustersetbindings.cluster.open-cluster-management.io +spec: + group: cluster.open-cluster-management.io + names: + kind: ManagedClusterSetBinding + listKind: ManagedClusterSetBindingList + plural: managedclustersetbindings + singular: managedclustersetbinding + scope: Namespaced + preserveUnknownFields: false + validation: + openAPIV3Schema: + description: ManagedClusterSetBinding projects a ManagedClusterSet into a certain + namespace. User is able to create a ManagedClusterSetBinding in a namespace + and bind it to a ManagedClusterSet if they have an RBAC rule to GET on the + virtual subresource of managedclustersets/bind. Workloads created in the same + namespace can only be distributed to ManagedClusters in ManagedClustersets + bound in this namespace by higher level controllers. + type: object + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec defines the attributes of ManagedClusterSetBinding. + type: object + properties: + clusterSet: + description: ClusterSet is the name of the ManagedClusterSet to bind. + User is allowed to set or update this field if they have an RBAC rule + to GET on the virtual subresource of managedclustersets/bind. + type: string + version: v1alpha1 + versions: + - name: v1alpha1 + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/deploy/webhook/webhook.yaml b/deploy/webhook/webhook.yaml index 28bd07ebc..c2e9a8286 100644 --- a/deploy/webhook/webhook.yaml +++ b/deploy/webhook/webhook.yaml @@ -53,3 +53,32 @@ webhooks: admissionReviewVersions: ["v1beta1"] sideEffects: None timeoutSeconds: 3 + +--- + +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: managedclustersetbindingvalidators.admission.cluster.open-cluster-management.io +webhooks: +- name: managedclustersetbindingvalidators.admission.cluster.open-cluster-management.io + failurePolicy: Fail + clientConfig: + service: + # reach the webhook via the registered aggregated API + namespace: default + name: kubernetes + path: /apis/admission.cluster.open-cluster-management.io/v1/managedclustersetbindingvalidators + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - cluster.open-cluster-management.io + apiVersions: + - "*" + resources: + - managedclustersetbindings + admissionReviewVersions: ["v1beta1"] + sideEffects: None + timeoutSeconds: 3 diff --git a/pkg/cmd/webhook/webhook.go b/pkg/cmd/webhook/webhook.go index 32be246e8..04820530d 100644 --- a/pkg/cmd/webhook/webhook.go +++ b/pkg/cmd/webhook/webhook.go @@ -3,14 +3,20 @@ package webhook import ( "os" - "github.com/open-cluster-management/registration/pkg/webhook" + clusterwebhook "github.com/open-cluster-management/registration/pkg/webhook/cluster" + clustersetbindingwebhook "github.com/open-cluster-management/registration/pkg/webhook/clustersetbinding" admissionserver "github.com/openshift/generic-admission-server/pkg/cmd/server" "github.com/spf13/cobra" genericapiserver "k8s.io/apiserver/pkg/server" ) func NewAdmissionHook() *cobra.Command { - o := admissionserver.NewAdmissionServerOptions(os.Stdout, os.Stderr, &webhook.ManagedClusterValidatingAdmissionHook{}, &webhook.ManagedClusterMutatingAdmissionHook{}) + o := admissionserver.NewAdmissionServerOptions( + os.Stdout, + os.Stderr, + &clusterwebhook.ManagedClusterValidatingAdmissionHook{}, + &clusterwebhook.ManagedClusterMutatingAdmissionHook{}, + &clustersetbindingwebhook.ManagedClusterSetBindingValidatingAdmissionHook{}) cmd := &cobra.Command{ Use: "webhook", diff --git a/pkg/webhook/mutating_webhook.go b/pkg/webhook/cluster/mutating_webhook.go similarity index 99% rename from pkg/webhook/mutating_webhook.go rename to pkg/webhook/cluster/mutating_webhook.go index 588c0148b..7af199a35 100644 --- a/pkg/webhook/mutating_webhook.go +++ b/pkg/webhook/cluster/mutating_webhook.go @@ -1,4 +1,4 @@ -package webhook +package cluster import ( "encoding/json" diff --git a/pkg/webhook/mutating_webhook_test.go b/pkg/webhook/cluster/mutating_webhook_test.go similarity index 99% rename from pkg/webhook/mutating_webhook_test.go rename to pkg/webhook/cluster/mutating_webhook_test.go index 990d225b7..98c61f6c7 100644 --- a/pkg/webhook/mutating_webhook_test.go +++ b/pkg/webhook/cluster/mutating_webhook_test.go @@ -1,4 +1,4 @@ -package webhook +package cluster import ( "encoding/json" diff --git a/pkg/webhook/validating_webhook.go b/pkg/webhook/cluster/validating_webhook.go similarity index 99% rename from pkg/webhook/validating_webhook.go rename to pkg/webhook/cluster/validating_webhook.go index c1edaefc6..3a110ce74 100644 --- a/pkg/webhook/validating_webhook.go +++ b/pkg/webhook/cluster/validating_webhook.go @@ -1,4 +1,4 @@ -package webhook +package cluster import ( "context" diff --git a/pkg/webhook/validating_webhook_test.go b/pkg/webhook/cluster/validating_webhook_test.go similarity index 99% rename from pkg/webhook/validating_webhook_test.go rename to pkg/webhook/cluster/validating_webhook_test.go index 49068b412..fa45adbc7 100644 --- a/pkg/webhook/validating_webhook_test.go +++ b/pkg/webhook/cluster/validating_webhook_test.go @@ -1,4 +1,4 @@ -package webhook +package cluster import ( "encoding/json" diff --git a/pkg/webhook/clustersetbinding/validating_webhook.go b/pkg/webhook/clustersetbinding/validating_webhook.go new file mode 100644 index 000000000..117cc2e2a --- /dev/null +++ b/pkg/webhook/clustersetbinding/validating_webhook.go @@ -0,0 +1,130 @@ +package clustersetbinding + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + clusterv1alpha1 "github.com/open-cluster-management/api/cluster/v1alpha1" + admissionv1beta1 "k8s.io/api/admission/v1beta1" + authenticationv1 "k8s.io/api/authentication/v1" + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/klog" +) + +// ManagedClusterSetBindingValidatingAdmissionHook will validate the creating/updating ManagedClusterSetBinding request. +type ManagedClusterSetBindingValidatingAdmissionHook struct { + kubeClient kubernetes.Interface +} + +// ValidatingResource is called by generic-admission-server on startup to register the returned REST resource through which the +// webhook is accessed by the kube apiserver. +func (a *ManagedClusterSetBindingValidatingAdmissionHook) ValidatingResource() (plural schema.GroupVersionResource, singular string) { + return schema.GroupVersionResource{ + Group: "admission.cluster.open-cluster-management.io", + Version: "v1", + Resource: "managedclustersetbindingvalidators", + }, + "managedclustersetbindingvalidators" +} + +// Validate is called by generic-admission-server when the registered REST resource above is called with an admission request. +func (a *ManagedClusterSetBindingValidatingAdmissionHook) Validate(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { + klog.V(4).Infof("validate %q operation for object %q", admissionSpec.Operation, admissionSpec.Object) + + // only validate the request for ManagedClusterSetBinding + if admissionSpec.Resource.Group != "cluster.open-cluster-management.io" || + admissionSpec.Resource.Resource != "managedclustersetbindings" { + return acceptRequest() + } + + // only handle Create/Update Operation + if admissionSpec.Operation != admissionv1beta1.Create && admissionSpec.Operation != admissionv1beta1.Update { + return acceptRequest() + } + + binding := &clusterv1alpha1.ManagedClusterSetBinding{} + if err := json.Unmarshal(admissionSpec.Object.Raw, binding); err != nil { + return denyRequest(http.StatusBadRequest, metav1.StatusReasonBadRequest, + fmt.Sprintf("Unable to unmarshal the ManagedClusterSetBinding object: %v", err)) + } + + // force the instance name to match the target cluster set name + if binding.Name != binding.Spec.ClusterSet { + return denyRequest(http.StatusBadRequest, metav1.StatusReasonBadRequest, + "The ManagedClusterSetBinding must have the same name as the target ManagedClusterSet") + } + + // check if the request user has permission to bind the target cluster set + if admissionSpec.Operation == admissionv1beta1.Create { + return a.allowBindingToClusterSet(binding.Spec.ClusterSet, admissionSpec.UserInfo) + } + + return acceptRequest() +} + +// Initialize is called by generic-admission-server on startup to setup initialization that ManagedClusterSetBinding webhook needs. +func (a *ManagedClusterSetBindingValidatingAdmissionHook) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { + var err error + a.kubeClient, err = kubernetes.NewForConfig(kubeClientConfig) + if err != nil { + return err + } + + return nil +} + +// allowBindingToClusterSet checks if the user has permission to bind a particular cluster set +func (a *ManagedClusterSetBindingValidatingAdmissionHook) allowBindingToClusterSet(clusterSetName string, userInfo authenticationv1.UserInfo) *admissionv1beta1.AdmissionResponse { + extra := make(map[string]authorizationv1.ExtraValue) + for k, v := range userInfo.Extra { + extra[k] = authorizationv1.ExtraValue(v) + } + + sar := &authorizationv1.SubjectAccessReview{ + Spec: authorizationv1.SubjectAccessReviewSpec{ + User: userInfo.Username, + UID: userInfo.UID, + Groups: userInfo.Groups, + Extra: extra, + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: "cluster.open-cluster-management.io", + Resource: "managedclustersets", + Subresource: "bind", + Verb: "create", + Name: clusterSetName, + }, + }, + } + sar, err := a.kubeClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err != nil { + return denyRequest(http.StatusForbidden, metav1.StatusReasonForbidden, err.Error()) + } + if !sar.Status.Allowed { + return denyRequest(http.StatusForbidden, metav1.StatusReasonForbidden, fmt.Sprintf("user %q is not allowed to bind cluster set %q", userInfo.Username, clusterSetName)) + } + return acceptRequest() +} + +func acceptRequest() *admissionv1beta1.AdmissionResponse { + return &admissionv1beta1.AdmissionResponse{ + Allowed: true, + } +} + +func denyRequest(code int32, reason metav1.StatusReason, message string) *admissionv1beta1.AdmissionResponse { + return &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, + Code: code, + Reason: reason, + Message: message, + }, + } +} diff --git a/pkg/webhook/clustersetbinding/validating_webhook_test.go b/pkg/webhook/clustersetbinding/validating_webhook_test.go new file mode 100644 index 000000000..233f391b5 --- /dev/null +++ b/pkg/webhook/clustersetbinding/validating_webhook_test.go @@ -0,0 +1,171 @@ +package clustersetbinding + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" + + clusterv1alpha1 "github.com/open-cluster-management/api/cluster/v1alpha1" + admissionv1beta1 "k8s.io/api/admission/v1beta1" + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kubefake "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" +) + +var managedclustersetbindingSchema = metav1.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1alpha1", + Resource: "managedclustersetbindings", +} + +func TestManagedClusterValidate(t *testing.T) { + cases := []struct { + name string + request *admissionv1beta1.AdmissionRequest + expectedResponse *admissionv1beta1.AdmissionResponse + allowBindingToClusterSet bool + }{ + { + name: "validate non-managedclustersetbindings request", + request: &admissionv1beta1.AdmissionRequest{ + Resource: metav1.GroupVersionResource{ + Group: "test.open-cluster-management.io", + Version: "v1", + Resource: "tests", + }, + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate deleting operation", + request: &admissionv1beta1.AdmissionRequest{ + Resource: managedclustersetbindingSchema, + Operation: admissionv1beta1.Delete, + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate creating cluster set binding", + request: &admissionv1beta1.AdmissionRequest{ + Resource: managedclustersetbindingSchema, + Operation: admissionv1beta1.Create, + Object: newManagedClusterSetBindingObj("ns1", "cs1", "cs1", nil), + }, + allowBindingToClusterSet: true, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate creating cluster set binding with unmatched name", + request: &admissionv1beta1.AdmissionRequest{ + Resource: managedclustersetbindingSchema, + Operation: admissionv1beta1.Create, + Object: newManagedClusterSetBindingObj("ns1", "csb1", "cs1", nil), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, + Message: "The ManagedClusterSetBinding must have the same name as the target ManagedClusterSet", + }, + }, + }, + { + name: "validate creating cluster set binding without permission", + request: &admissionv1beta1.AdmissionRequest{ + Resource: managedclustersetbindingSchema, + Operation: admissionv1beta1.Create, + Object: newManagedClusterSetBindingObj("ns1", "cs1", "cs1", nil), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, + Message: "user \"\" is not allowed to bind cluster set \"cs1\"", + }, + }, + }, + { + name: "validate updating cluster set binding", + request: &admissionv1beta1.AdmissionRequest{ + Resource: managedclustersetbindingSchema, + Operation: admissionv1beta1.Update, + Object: newManagedClusterSetBindingObj("ns1", "cs1", "cs1", nil), + OldObject: newManagedClusterSetBindingObj("ns1", "cs1", "cs2", map[string]string{ + "team": "team1", + }), + }, + allowBindingToClusterSet: true, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate updating cluster set binding with different cluster set", + request: &admissionv1beta1.AdmissionRequest{ + Resource: managedclustersetbindingSchema, + Operation: admissionv1beta1.Update, + Object: newManagedClusterSetBindingObj("ns1", "cs1", "cs2", nil), + OldObject: newManagedClusterSetBindingObj("ns1", "cs1", "cs1", nil), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, + Message: "The ManagedClusterSetBinding must have the same name as the target ManagedClusterSet", + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kubeClient := kubefake.NewSimpleClientset() + kubeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, &authorizationv1.SubjectAccessReview{ + Status: authorizationv1.SubjectAccessReviewStatus{ + Allowed: c.allowBindingToClusterSet, + }, + }, nil + }, + ) + + admissionHook := &ManagedClusterSetBindingValidatingAdmissionHook{ + kubeClient: kubeClient, + } + + actualResponse := admissionHook.Validate(c.request) + if !reflect.DeepEqual(actualResponse, c.expectedResponse) { + t.Errorf("expected %#v but got: %#v", c.expectedResponse.Result, actualResponse.Result) + } + }) + } +} + +func newManagedClusterSetBindingObj(namespace, name, clusterSetName string, labels map[string]string) runtime.RawExtension { + managedClusterSetBinding := &clusterv1alpha1.ManagedClusterSetBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Labels: labels, + }, + Spec: clusterv1alpha1.ManagedClusterSetBindingSpec{ + ClusterSet: clusterSetName, + }, + } + bindingObj, _ := json.Marshal(managedClusterSetBinding) + return runtime.RawExtension{ + Raw: bindingObj, + } +} diff --git a/test/e2e/webhook_test.go b/test/e2e/webhook_test.go index 769b7eb4e..92dd8704c 100644 --- a/test/e2e/webhook_test.go +++ b/test/e2e/webhook_test.go @@ -11,6 +11,7 @@ import ( clusterv1client "github.com/open-cluster-management/api/client/cluster/clientset/versioned" clusterv1 "github.com/open-cluster-management/api/cluster/v1" + clusterv1alpha1 "github.com/open-cluster-management/api/cluster/v1alpha1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -25,13 +26,14 @@ import ( const ( apiserviceName = "v1.admission.cluster.open-cluster-management.io" - admissionName = "managedclustervalidators.admission.cluster.open-cluster-management.io" invalidURL = "127.0.0.1:8001" validURL = "https://127.0.0.1:8443" saNamespace = "default" ) -var _ = ginkgo.Describe("Managed cluster admission hook", func() { +var _ = ginkgo.Describe("Admission webhook", func() { + var admissionName string + ginkgo.BeforeEach(func() { // make sure the api service v1.admission.cluster.open-cluster-management.io is available gomega.Eventually(func() bool { @@ -45,145 +47,312 @@ var _ = ginkgo.Describe("Managed cluster admission hook", func() { return apiService.Status.Conditions[0].Type == apiregistrationv1.Available && apiService.Status.Conditions[0].Status == apiregistrationv1.ConditionTrue }, 60*time.Second, 1*time.Second).Should(gomega.BeTrue()) - // make sure the managedcluster can be created successfully - gomega.Eventually(func() bool { - clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) - managedCluster := newManagedCluster(clusterName, false, validURL) - _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) - if err != nil { - return false - } - clusterClient.ClusterV1().ManagedClusters().Delete(context.TODO(), clusterName, metav1.DeleteOptions{}) - return true - }, 60*time.Second, 1*time.Second).Should(gomega.BeTrue()) }) - ginkgo.Context("Creating a managed cluster", func() { - ginkgo.It("Should have the default LeaseDurationSeconds", func() { - clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) - ginkgo.By(fmt.Sprintf("create a managed cluster %q", clusterName)) + ginkgo.Context("ManagedCluster", func() { + ginkgo.BeforeEach(func() { + admissionName = "managedclustervalidators.admission.cluster.open-cluster-management.io" - _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), newManagedCluster(clusterName, false, validURL), metav1.CreateOptions{}) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - - managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(managedCluster.Spec.LeaseDurationSeconds).To(gomega.Equal(int32(60))) + // make sure the managedcluster can be created successfully + gomega.Eventually(func() bool { + clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) + managedCluster := newManagedCluster(clusterName, false, validURL) + _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) + if err != nil { + return false + } + clusterClient.ClusterV1().ManagedClusters().Delete(context.TODO(), clusterName, metav1.DeleteOptions{}) + return true + }, 60*time.Second, 1*time.Second).Should(gomega.BeTrue()) }) - ginkgo.It("Should respond bad request when creating a managed cluster with invalid external server URLs", func() { - clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) - ginkgo.By(fmt.Sprintf("create a managed cluster %q with an invalid external server URL %q", clusterName, invalidURL)) + ginkgo.Context("Creating a managed cluster", func() { + ginkgo.It("Should have the default LeaseDurationSeconds", func() { + clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) + ginkgo.By(fmt.Sprintf("create a managed cluster %q", clusterName)) - managedCluster := newManagedCluster(clusterName, false, invalidURL) + _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), newManagedCluster(clusterName, false, validURL), metav1.CreateOptions{}) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) - _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue()) - gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( - "admission webhook \"%s\" denied the request: url \"%s\" is invalid in client configs", - admissionName, - invalidURL, - ))) + managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(managedCluster.Spec.LeaseDurationSeconds).To(gomega.Equal(int32(60))) + }) + + ginkgo.It("Should respond bad request when creating a managed cluster with invalid external server URLs", func() { + clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) + ginkgo.By(fmt.Sprintf("create a managed cluster %q with an invalid external server URL %q", clusterName, invalidURL)) + + managedCluster := newManagedCluster(clusterName, false, invalidURL) + + _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: url \"%s\" is invalid in client configs", + admissionName, + invalidURL, + ))) + }) + + ginkgo.It("Should forbid the request when creating an accepted managed cluster by unauthorized user", func() { + sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) + clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) + + ginkgo.By(fmt.Sprintf("create an managed cluster %q with unauthorized service account %q", clusterName, sa)) + + // prepare an unauthorized cluster client from a service account who can create/get/update ManagedCluster + // but cannot change the ManagedCluster HubAcceptsClient field + unauthorizedClient, err := buildClusterClient(saNamespace, sa, []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclusters"}, + Verbs: []string{"create", "get", "update"}, + }, + }, nil) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + managedCluster := newManagedCluster(clusterName, true, validURL) + + _, err = unauthorizedClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsForbidden(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: user \"system:serviceaccount:%s:%s\" cannot update the HubAcceptsClient field", + admissionName, + saNamespace, + sa, + ))) + }) }) - ginkgo.It("Should forbid the request when creating an accepted managed cluster by unauthorized user", func() { - sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) - clusterName := fmt.Sprintf("webhook-spoke-%s", rand.String(6)) + ginkgo.Context("Updating a managed cluster", func() { + var clusterName string - ginkgo.By(fmt.Sprintf("create an managed cluster %q with unauthorized service account %q", clusterName, sa)) + ginkgo.BeforeEach(func() { + clusterName = fmt.Sprintf("webhook-spoke-%s", rand.String(6)) + managedCluster := newManagedCluster(clusterName, false, validURL) + _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) - // prepare an unauthorized cluster client from a service account who can create/get/update ManagedCluster - // but cannot change the ManagedCluster HubAcceptsClient field - unauthorizedClient, err := buildUnauthorizedClusterClient(sa) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) + ginkgo.It("Should not update the LeaseDurationSeconds to zero", func() { + ginkgo.By(fmt.Sprintf("try to update managed cluster %q LeaseDurationSeconds to zero", clusterName)) + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) - managedCluster := newManagedCluster(clusterName, true, validURL) + managedCluster.Spec.LeaseDurationSeconds = 0 + _, err = clusterClient.ClusterV1().ManagedClusters().Update(context.TODO(), managedCluster, metav1.UpdateOptions{}) + return err + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) - _, err = unauthorizedClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(errors.IsForbidden(err)).Should(gomega.BeTrue()) - gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( - "admission webhook \"%s\" denied the request: user \"system:serviceaccount:%s:%s\" cannot update the HubAcceptsClient field", - admissionName, - saNamespace, - sa, - ))) + managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(managedCluster.Spec.LeaseDurationSeconds).To(gomega.Equal(int32(60))) + }) + + ginkgo.It("Should respond bad request when updating a managed cluster with invalid external server URLs", func() { + ginkgo.By(fmt.Sprintf("update managed cluster %q with an invalid external server URL %q", clusterName, invalidURL)) + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + managedCluster.Spec.ManagedClusterClientConfigs[0].URL = invalidURL + _, err = clusterClient.ClusterV1().ManagedClusters().Update(context.TODO(), managedCluster, metav1.UpdateOptions{}) + return err + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: url \"%s\" is invalid in client configs", + admissionName, + invalidURL, + ))) + }) + + ginkgo.It("Should forbid the request when updating an unaccepted managed cluster to accepted by unauthorized user", func() { + sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) + ginkgo.By(fmt.Sprintf("accept managed cluster %q by an unauthorized user %q", clusterName, sa)) + + // prepare an unauthorized cluster client from a service account who can create/get/update ManagedCluster + // but cannot change the ManagedCluster HubAcceptsClient field + unauthorizedClient, err := buildClusterClient(saNamespace, sa, []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclusters"}, + Verbs: []string{"create", "get", "update"}, + }, + }, nil) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + managedCluster, err := unauthorizedClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + managedCluster.Spec.HubAcceptsClient = true + _, err = unauthorizedClient.ClusterV1().ManagedClusters().Update(context.TODO(), managedCluster, metav1.UpdateOptions{}) + return err + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsForbidden(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: user \"system:serviceaccount:%s:%s\" cannot update the HubAcceptsClient field", + admissionName, + saNamespace, + sa, + ))) + }) }) }) - ginkgo.Context("Updating a managed cluster", func() { - var clusterName string + ginkgo.Context("ManagedClusterSetBinding", func() { + var namespace string ginkgo.BeforeEach(func() { - clusterName = fmt.Sprintf("webhook-spoke-%s", rand.String(6)) - managedCluster := newManagedCluster(clusterName, false, validURL) - _, err := clusterClient.ClusterV1().ManagedClusters().Create(context.TODO(), managedCluster, metav1.CreateOptions{}) + admissionName = "managedclustersetbindingvalidators.admission.cluster.open-cluster-management.io" + + // create a namespace for testing + namespace = fmt.Sprintf("ns-%s", rand.String(6)) + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + _, err := hubClient.CoreV1().Namespaces().Create(context.TODO(), ns, metav1.CreateOptions{}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + // make sure the managedclusterset can be created successfully + gomega.Eventually(func() bool { + clusterSetName := fmt.Sprintf("clusterset-%s", rand.String(6)) + managedClusterSetBinding := newManagedClusterSetBinding(namespace, clusterSetName, clusterSetName) + _, err := clusterClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Create(context.TODO(), managedClusterSetBinding, metav1.CreateOptions{}) + if err != nil { + return false + } + clusterClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Delete(context.TODO(), clusterSetName, metav1.DeleteOptions{}) + return true + }, 60*time.Second, 1*time.Second).Should(gomega.BeTrue()) }) - ginkgo.It("Should not update the LeaseDurationSeconds to zero", func() { - ginkgo.By(fmt.Sprintf("try to update managed cluster %q LeaseDurationSeconds to zero", clusterName)) - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - managedCluster.Spec.LeaseDurationSeconds = 0 - _, err = clusterClient.ClusterV1().ManagedClusters().Update(context.TODO(), managedCluster, metav1.UpdateOptions{}) - return err - }) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - - managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(managedCluster.Spec.LeaseDurationSeconds).To(gomega.Equal(int32(60))) + ginkgo.AfterEach(func() { + hubClient.CoreV1().Namespaces().Delete(context.TODO(), namespace, metav1.DeleteOptions{}) }) - ginkgo.It("Should respond bad request when updating a managed cluster with invalid external server URLs", func() { - ginkgo.By(fmt.Sprintf("update managed cluster %q with an invalid external server URL %q", clusterName, invalidURL)) - - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - managedCluster, err := clusterClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) - gomega.Expect(err).NotTo(gomega.HaveOccurred()) - - managedCluster.Spec.ManagedClusterClientConfigs[0].URL = invalidURL - _, err = clusterClient.ClusterV1().ManagedClusters().Update(context.TODO(), managedCluster, metav1.UpdateOptions{}) - return err + ginkgo.Context("Creating a ManagedClusterSetBinding", func() { + ginkgo.It("should deny the request when creating a ManagedClusterSetBinding with unmatched cluster set name", func() { + clusterSetName := fmt.Sprintf("clusterset-%s", rand.String(6)) + clusterSetBindingName := fmt.Sprintf("clustersetbinding-%s", rand.String(6)) + managedClusterSetBinding := newManagedClusterSetBinding(namespace, clusterSetBindingName, clusterSetName) + _, err := clusterClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Create(context.TODO(), managedClusterSetBinding, metav1.CreateOptions{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: The ManagedClusterSetBinding must have the same name as the target ManagedClusterSet", + admissionName, + ))) + }) + + ginkgo.It("should accept the request when creating a ManagedClusterSetBinding by authorized user", func() { + sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) + clusterSetName := fmt.Sprintf("clusterset-%s", rand.String(6)) + + authorizedClient, err := buildClusterClient(namespace, sa, []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclustersets/bind"}, + Verbs: []string{"create"}, + }, + }, []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclustersetbindings"}, + Verbs: []string{"create", "get", "update"}, + }, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + managedClusterSetBinding := newManagedClusterSetBinding(namespace, clusterSetName, clusterSetName) + _, err = authorizedClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Create(context.TODO(), managedClusterSetBinding, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should forbid the request when creating a ManagedClusterSetBinding by unauthorized user", func() { + sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) + clusterSetName := fmt.Sprintf("clusterset-%s", rand.String(6)) + + // prepare an unauthorized cluster client from a service account who can create/get/update ManagedClusterSetBinding + // but cannot bind ManagedClusterSet + unauthorizedClient, err := buildClusterClient(namespace, sa, nil, []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclustersetbindings"}, + Verbs: []string{"create", "get", "update"}, + }, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + managedClusterSetBinding := newManagedClusterSetBinding(namespace, clusterSetName, clusterSetName) + _, err = unauthorizedClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Create(context.TODO(), managedClusterSetBinding, metav1.CreateOptions{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsForbidden(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: user \"system:serviceaccount:%s:%s\" is not allowed to bind cluster set \"%s\"", + admissionName, + namespace, + sa, + clusterSetName, + ))) }) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue()) - gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( - "admission webhook \"%s\" denied the request: url \"%s\" is invalid in client configs", - admissionName, - invalidURL, - ))) }) - ginkgo.It("Should forbid the request when updating an unaccepted managed cluster to accepted by unauthorized user", func() { - sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) - ginkgo.By(fmt.Sprintf("accept managed cluster %q by an unauthorized user %q", clusterName, sa)) - - // prepare an unauthorized cluster client from a service account who can create/get/update ManagedCluster - // but cannot change the ManagedCluster HubAcceptsClient field - unauthorizedClient, err := buildUnauthorizedClusterClient(sa) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - - err = retry.RetryOnConflict(retry.DefaultRetry, func() error { - managedCluster, err := unauthorizedClient.ClusterV1().ManagedClusters().Get(context.TODO(), clusterName, metav1.GetOptions{}) + ginkgo.Context("Updating a ManagedClusterSetBinding", func() { + ginkgo.It("should deny the request when updating a ManagedClusterSetBinding with a new cluster set", func() { + // create a cluster set binding + clusterSetName := fmt.Sprintf("clusterset-%s", rand.String(6)) + managedClusterSetBinding := newManagedClusterSetBinding(namespace, clusterSetName, clusterSetName) + managedClusterSetBinding, err := clusterClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Create(context.TODO(), managedClusterSetBinding, metav1.CreateOptions{}) gomega.Expect(err).NotTo(gomega.HaveOccurred()) - managedCluster.Spec.HubAcceptsClient = true - _, err = unauthorizedClient.ClusterV1().ManagedClusters().Update(context.TODO(), managedCluster, metav1.UpdateOptions{}) - return err + // update the cluster set binding + clusterSetName = fmt.Sprintf("clusterset-%s", rand.String(6)) + managedClusterSetBinding.Spec.ClusterSet = clusterSetName + _, err = clusterClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Update(context.TODO(), managedClusterSetBinding, metav1.UpdateOptions{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue()) + gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( + "admission webhook \"%s\" denied the request: The ManagedClusterSetBinding must have the same name as the target ManagedClusterSet", + admissionName, + ))) + }) + + ginkgo.It("should accept the request when updating the label of the ManagedClusterSetBinding by user without binding permission", func() { + // create a cluster set binding + clusterSetName := fmt.Sprintf("clusterset-%s", rand.String(6)) + managedClusterSetBinding := newManagedClusterSetBinding(namespace, clusterSetName, clusterSetName) + managedClusterSetBinding, err := clusterClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Create(context.TODO(), managedClusterSetBinding, metav1.CreateOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + // create a client without clusterset binding permission + sa := fmt.Sprintf("webhook-sa-%s", rand.String(6)) + unauthorizedClient, err := buildClusterClient(namespace, sa, nil, []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclustersetbindings"}, + Verbs: []string{"create", "get", "update"}, + }, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + // update the cluster set binding by authorized user + managedClusterSetBinding.Labels = map[string]string{ + "owner": "user1", + } + _, err = unauthorizedClient.ClusterV1alpha1().ManagedClusterSetBindings(namespace).Update(context.TODO(), managedClusterSetBinding, metav1.UpdateOptions{}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(errors.IsForbidden(err)).Should(gomega.BeTrue()) - gomega.Expect(err.Error()).Should(gomega.Equal(fmt.Sprintf( - "admission webhook \"%s\" denied the request: user \"system:serviceaccount:%s:%s\" cannot update the HubAcceptsClient field", - admissionName, - saNamespace, - sa, - ))) }) }) }) @@ -204,7 +373,19 @@ func newManagedCluster(name string, accepted bool, externalURL string) *clusterv } } -func buildUnauthorizedClusterClient(saName string) (clusterv1client.Interface, error) { +func newManagedClusterSetBinding(namespace, name string, clusterSet string) *clusterv1alpha1.ManagedClusterSetBinding { + return &clusterv1alpha1.ManagedClusterSetBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: clusterv1alpha1.ManagedClusterSetBindingSpec{ + ClusterSet: clusterSet, + }, + } +} + +func buildClusterClient(saNamespace, saName string, clusterPolicyRules, policyRules []rbacv1.PolicyRule) (clusterv1client.Interface, error) { var err error sa := &corev1.ServiceAccount{ @@ -218,44 +399,86 @@ func buildUnauthorizedClusterClient(saName string) (clusterv1client.Interface, e return nil, err } - clusterRoleName := fmt.Sprintf("%s-clusterrole", saName) - clusterRole := &rbacv1.ClusterRole{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterRoleName, - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{"cluster.open-cluster-management.io"}, - Resources: []string{"managedclusters"}, - Verbs: []string{"create", "get", "update"}, + // create cluster role/rolebinding + if len(clusterPolicyRules) > 0 { + clusterRoleName := fmt.Sprintf("%s-clusterrole", saName) + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, }, - }, - } - _, err = hubClient.RbacV1().ClusterRoles().Create(context.TODO(), clusterRole, metav1.CreateOptions{}) - if err != nil { - return nil, err + Rules: clusterPolicyRules, + } + _, err = hubClient.RbacV1().ClusterRoles().Create(context.TODO(), clusterRole, metav1.CreateOptions{}) + if err != nil { + return nil, err + } + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-clusterrolebinding", saName), + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: saNamespace, + Name: saName, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: clusterRoleName, + }, + } + _, err = hubClient.RbacV1().ClusterRoleBindings().Create(context.TODO(), clusterRoleBinding, metav1.CreateOptions{}) + if err != nil { + return nil, err + } } - clusterRoleBinding := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-clusterrolebinding", saName), - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", + // create cluster role/rolebinding + if len(policyRules) > 0 { + roleName := fmt.Sprintf("%s-role", saName) + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ Namespace: saNamespace, - Name: saName, + Name: roleName, }, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: clusterRoleName, - }, - } - _, err = hubClient.RbacV1().ClusterRoleBindings().Create(context.TODO(), clusterRoleBinding, metav1.CreateOptions{}) - if err != nil { - return nil, err + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.open-cluster-management.io"}, + Resources: []string{"managedclustersetbindings"}, + Verbs: []string{"create", "get", "update"}, + }, + }, + } + _, err = hubClient.RbacV1().Roles(saNamespace).Create(context.TODO(), role, metav1.CreateOptions{}) + if err != nil { + return nil, err + } + + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: saNamespace, + Name: fmt.Sprintf("%s-rolebinding", saName), + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: saNamespace, + Name: saName, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: roleName, + }, + } + _, err = hubClient.RbacV1().RoleBindings(saNamespace).Create(context.TODO(), roleBinding, metav1.CreateOptions{}) + if err != nil { + return nil, err + } } var tokenSecret *corev1.Secret