package cluster import ( "context" "encoding/json" "fmt" "net/http" clusterv1 "github.com/open-cluster-management/api/cluster/v1" "github.com/open-cluster-management/registration/pkg/helpers" operatorhelpers "github.com/openshift/library-go/pkg/operator/v1helpers" 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" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/klog/v2" ) const ( clusterSetLabel = "cluster.open-cluster-management.io/clusterset" ) // ManagedClusterValidatingAdmissionHook will validate the creating/updating managedcluster request. type ManagedClusterValidatingAdmissionHook 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 *ManagedClusterValidatingAdmissionHook) ValidatingResource() (plural schema.GroupVersionResource, singular string) { return schema.GroupVersionResource{ Group: "admission.cluster.open-cluster-management.io", Version: "v1", Resource: "managedclustervalidators", }, "managedclustervalidators" } // Validate is called by generic-admission-server when the registered REST resource above is called with an admission request. func (a *ManagedClusterValidatingAdmissionHook) Validate(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { klog.V(4).Infof("validate %q operation for object %q", admissionSpec.Operation, admissionSpec.Object) status := &admissionv1beta1.AdmissionResponse{} // only validate the request for managedcluster if admissionSpec.Resource.Group != "cluster.open-cluster-management.io" || admissionSpec.Resource.Resource != "managedclusters" { status.Allowed = true return status } switch admissionSpec.Operation { case admissionv1beta1.Create: return a.validateCreateRequest(admissionSpec) case admissionv1beta1.Update: return a.validateUpdateRequest(admissionSpec) default: status.Allowed = true return status } } // Initialize is called by generic-admission-server on startup to setup initialization that managedclusters webhook needs. func (a *ManagedClusterValidatingAdmissionHook) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { var err error a.kubeClient, err = kubernetes.NewForConfig(kubeClientConfig) return err } // validateCreateRequest validates create managed cluster operation func (a *ManagedClusterValidatingAdmissionHook) validateCreateRequest(request *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { status := &admissionv1beta1.AdmissionResponse{} // validate ManagedCluster object firstly managedCluster, err := a.validateManagedClusterObj(request.Object) if err != nil { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, Message: err.Error(), } return status } if managedCluster.Spec.HubAcceptsClient { // the HubAcceptsClient field is changed, we need to check the request user whether // has been allowed to change the HubAcceptsClient field with SubjectAccessReview api if status := a.allowUpdateAcceptField(managedCluster.Name, request.UserInfo); !status.Allowed { return status } } // check whether the request user has been allowed to set clusterset label var clusterSetName string if len(managedCluster.Labels) > 0 { clusterSetName = managedCluster.Labels[clusterSetLabel] } return a.allowSetClusterSetLabel(request.UserInfo, "", clusterSetName) } // validateUpdateRequest validates update managed cluster operation. func (a *ManagedClusterValidatingAdmissionHook) validateUpdateRequest(request *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { status := &admissionv1beta1.AdmissionResponse{} oldManagedCluster := &clusterv1.ManagedCluster{} if err := json.Unmarshal(request.OldObject.Raw, oldManagedCluster); err != nil { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, Message: err.Error(), } return status } // validate the updating ManagedCluster object firstly newManagedCluster, err := a.validateManagedClusterObj(request.Object) if err != nil { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, Message: err.Error(), } return status } if newManagedCluster.Spec.HubAcceptsClient != oldManagedCluster.Spec.HubAcceptsClient { // the HubAcceptsClient field is changed, we need to check the request user whether // has been allowed to update the HubAcceptsClient field with SubjectAccessReview api if status := a.allowUpdateAcceptField(newManagedCluster.Name, request.UserInfo); !status.Allowed { return status } } // check whether the request user has been allowed to set clusterset label var originalClusterSetName, currentClusterSetName string if len(oldManagedCluster.Labels) > 0 { originalClusterSetName = oldManagedCluster.Labels[clusterSetLabel] } if len(newManagedCluster.Labels) > 0 { currentClusterSetName = newManagedCluster.Labels[clusterSetLabel] } return a.allowSetClusterSetLabel(request.UserInfo, originalClusterSetName, currentClusterSetName) } // validateManagedClusterObj validates the fileds of ManagedCluster object func (a *ManagedClusterValidatingAdmissionHook) validateManagedClusterObj(requestObj runtime.RawExtension) (*clusterv1.ManagedCluster, error) { errs := []error{} managedCluster := &clusterv1.ManagedCluster{} if err := json.Unmarshal(requestObj.Raw, managedCluster); err != nil { errs = append(errs, err) } // there are no spoke client configs, finish the validation process if len(managedCluster.Spec.ManagedClusterClientConfigs) == 0 { return managedCluster, operatorhelpers.NewMultiLineAggregate(errs) } // validate the url in spoke client configs for _, clientConfig := range managedCluster.Spec.ManagedClusterClientConfigs { if !helpers.IsValidHTTPSURL(clientConfig.URL) { errs = append(errs, fmt.Errorf("url %q is invalid in client configs", clientConfig.URL)) } } return managedCluster, operatorhelpers.NewMultiLineAggregate(errs) } // allowUpdateHubAcceptsClientField using SubjectAccessReview API to check whether a request user has been authorized to update // HubAcceptsClient field func (a *ManagedClusterValidatingAdmissionHook) allowUpdateAcceptField(clusterName string, userInfo authenticationv1.UserInfo) *admissionv1beta1.AdmissionResponse { status := &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: "register.open-cluster-management.io", Resource: "managedclusters", Verb: "update", Subresource: "accept", Name: clusterName, }, }, } sar, err := a.kubeClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) if err != nil { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, Message: err.Error(), } return status } if !sar.Status.Allowed { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, Message: fmt.Sprintf("user %q cannot update the HubAcceptsClient field", userInfo.Username), } return status } status.Allowed = true return status } // allowSetClusterSetLabel checks whether a request user has been authorized to set clusterset label func (a *ManagedClusterValidatingAdmissionHook) allowSetClusterSetLabel(userInfo authenticationv1.UserInfo, originalClusterSet, newClusterSet string) *admissionv1beta1.AdmissionResponse { if originalClusterSet == newClusterSet { return &admissionv1beta1.AdmissionResponse{Allowed: true} } if len(originalClusterSet) > 0 { if status := a.allowUpdateClusterSet(userInfo, originalClusterSet); !status.Allowed { return status } } if len(newClusterSet) > 0 { if status := a.allowUpdateClusterSet(userInfo, newClusterSet); !status.Allowed { return status } } return &admissionv1beta1.AdmissionResponse{ Allowed: true, } } // allowUpdateClusterSet checks whether a request user has been authorized to add/remove a ManagedCluster // to/from the ManagedClusterSet func (a *ManagedClusterValidatingAdmissionHook) allowUpdateClusterSet(userInfo authenticationv1.UserInfo, clusterSetName string) *admissionv1beta1.AdmissionResponse { status := &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: "join", Name: clusterSetName, Verb: "create", }, }, } sar, err := a.kubeClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) if err != nil { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, Message: err.Error(), } return status } if !sar.Status.Allowed { status.Allowed = false status.Result = &metav1.Status{ Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, Message: fmt.Sprintf("user %q cannot add/remove a ManagedCluster to/from ManagedClusterSet %q", userInfo.Username, clusterSetName), } return status } status.Allowed = true return status }