Merge pull request #67 from elgnay/csb-webhook

Add validating webhook for ManagedClusterSetBinding
This commit is contained in:
OpenShift Merge Robot
2020-08-27 02:46:15 -04:00
committed by GitHub
11 changed files with 768 additions and 152 deletions

View File

@@ -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

View File

@@ -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: []

View File

@@ -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

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
package webhook
package cluster
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package webhook
package cluster
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package webhook
package cluster
import (
"context"

View File

@@ -1,4 +1,4 @@
package webhook
package cluster
import (
"encoding/json"

View File

@@ -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,
},
}
}

View File

@@ -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,
}
}

View File

@@ -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