From 69b6581213c12e1a9599ece5073549f8f709f283 Mon Sep 17 00:00:00 2001 From: Rajiv Senthilnathan Date: Wed, 24 Jan 2024 18:42:20 -0500 Subject: [PATCH] Add webhook for CNV SSP resources (#527) * Add SSP webhook * Add tests * Add more tests --- cmd/webhook/main.go | 4 + deploy/webhook/member-operator-webhook.yaml | 24 +++ pkg/webhook/deploy/deployment_test.go | 2 +- .../validatingwebhook/validate_ssp_request.go | 70 +++++++ .../validate_ssp_request_test.go | 180 ++++++++++++++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 pkg/webhook/validatingwebhook/validate_ssp_request.go create mode 100644 pkg/webhook/validatingwebhook/validate_ssp_request_test.go diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index 28be7a6f..7f0f9b69 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -105,12 +105,16 @@ func main() { spacebindingrequestValidator := &validatingwebhook.SpaceBindingRequestValidator{ Client: cl, } + sspRequestValidator := &validatingwebhook.SSPRequestValidator{ + Client: cl, + } mux := http.NewServeMux() mux.HandleFunc("/mutate-users-pods", mutatingwebhook.HandleMutateUserPods) mux.HandleFunc("/mutate-virtual-machines", mutatingwebhook.HandleMutateVirtualMachines) mux.HandleFunc("/validate-users-rolebindings", rolebindingValidator.HandleValidate) mux.HandleFunc("/validate-spacebindingrequests", spacebindingrequestValidator.HandleValidate) + mux.HandleFunc("/validate-ssprequests", sspRequestValidator.HandleValidate) // SSP is a CNV specific resource webhookServer := &http.Server{ //nolint:gosec //TODO: configure ReadHeaderTimeout (gosec G112) Addr: ":8443", diff --git a/deploy/webhook/member-operator-webhook.yaml b/deploy/webhook/member-operator-webhook.yaml index cee45e37..f5a4222a 100644 --- a/deploy/webhook/member-operator-webhook.yaml +++ b/deploy/webhook/member-operator-webhook.yaml @@ -247,6 +247,30 @@ objects: namespaceSelector: matchLabels: toolchain.dev.openshift.com/provider: codeready-toolchain + - name: users.virtualmachines.ssp.webhook.sandbox + admissionReviewVersions: + - v1 + clientConfig: + caBundle: ${CA_BUNDLE} + service: + name: member-operator-webhook + namespace: ${NAMESPACE} + path: "/validate-ssprequests" + port: 443 + matchPolicy: Equivalent + rules: + - operations: ["CREATE", "UPDATE"] + apiGroups: ["ssp.kubevirt.io"] + apiVersions: ["*"] + resources: ["ssps"] + scope: "Namespaced" + sideEffects: None + timeoutSeconds: 5 + reinvocationPolicy: Never + failurePolicy: Fail + namespaceSelector: + matchLabels: + toolchain.dev.openshift.com/provider: codeready-toolchain parameters: - name: NAMESPACE value: 'toolchain-member-operator' diff --git a/pkg/webhook/deploy/deployment_test.go b/pkg/webhook/deploy/deployment_test.go index 7fedf38a..dbd40bbd 100644 --- a/pkg/webhook/deploy/deployment_test.go +++ b/pkg/webhook/deploy/deployment_test.go @@ -227,7 +227,7 @@ func mutatingWebhookConfig(namespace, caBundle string) string { } func validatingWebhookConfig(namespace, caBundle string) string { - return fmt.Sprintf(`{"apiVersion": "admissionregistration.k8s.io/v1","kind": "ValidatingWebhookConfiguration","metadata": {"labels": {"app": "member-operator-webhook","toolchain.dev.openshift.com/provider": "codeready-toolchain"},"name": "member-operator-validating-webhook"},"webhooks": [{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-users-rolebindings","port": 443}},"failurePolicy": "Ignore","matchPolicy": "Equivalent","name": "users.rolebindings.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["rbac.authorization.k8s.io","authorization.openshift.io"],"apiVersions": ["v1"],"operations": ["CREATE","UPDATE"],"resources": ["rolebindings"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-spacebindingrequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.spacebindingrequests.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["toolchain.dev.openshift.com"],"apiVersions": ["v1alpha1"],"operations": ["CREATE","UPDATE"],"resources": ["spacebindingrequests"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5}]}`, caBundle, namespace) + return fmt.Sprintf(`{"apiVersion": "admissionregistration.k8s.io/v1","kind": "ValidatingWebhookConfiguration","metadata": {"labels": {"app": "member-operator-webhook","toolchain.dev.openshift.com/provider": "codeready-toolchain"},"name": "member-operator-validating-webhook"},"webhooks": [{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-users-rolebindings","port": 443}},"failurePolicy": "Ignore","matchPolicy": "Equivalent","name": "users.rolebindings.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["rbac.authorization.k8s.io","authorization.openshift.io"],"apiVersions": ["v1"],"operations": ["CREATE","UPDATE"],"resources": ["rolebindings"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-spacebindingrequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.spacebindingrequests.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["toolchain.dev.openshift.com"],"apiVersions": ["v1alpha1"],"operations": ["CREATE","UPDATE"],"resources": ["spacebindingrequests"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-ssprequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.virtualmachines.ssp.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["ssp.kubevirt.io"],"apiVersions": ["*"],"operations": ["CREATE","UPDATE"],"resources": ["ssps"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5}]}`, caBundle, namespace) } func serviceAccount(namespace string) string { diff --git a/pkg/webhook/validatingwebhook/validate_ssp_request.go b/pkg/webhook/validatingwebhook/validate_ssp_request.go new file mode 100644 index 00000000..22c841c9 --- /dev/null +++ b/pkg/webhook/validatingwebhook/validate_ssp_request.go @@ -0,0 +1,70 @@ +package validatingwebhook + +import ( + "context" + "html" + "io" + "net/http" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + + userv1 "github.com/openshift/api/user/v1" + "github.com/pkg/errors" + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/types" + runtimeClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// The SSP resources is a CNV specific resource +type SSPRequestValidator struct { + Client runtimeClient.Client +} + +func (v SSPRequestValidator) HandleValidate(w http.ResponseWriter, r *http.Request) { + var respBody []byte + body, err := io.ReadAll(r.Body) + defer func() { + if err := r.Body.Close(); err != nil { + log.Error(err, "unable to close the body") + } + }() + if err != nil { + log.Error(err, "unable to read the body of the request") + w.WriteHeader(http.StatusInternalServerError) + respBody = []byte("unable to read the body of the request") + } else { + // validate the request + respBody = v.validate(r.Context(), body) + w.WriteHeader(http.StatusOK) + } + if _, err := io.WriteString(w, string(respBody)); err != nil { + log.Error(err, "unable to write response") + } +} + +func (v SSPRequestValidator) validate(ctx context.Context, body []byte) []byte { + log.Info("incoming request", "body", string(body)) + admReview := admissionv1.AdmissionReview{} + if _, _, err := deserializer.Decode(body, nil, &admReview); err != nil { + // sanitize the body + escapedBody := html.EscapeString(string(body)) + log.Error(err, "unable to deserialize the admission review object", "body", escapedBody) + return denyAdmissionRequest(admReview, errors.Wrapf(err, "unable to deserialize the admission review object - body: %v", escapedBody)) + } + //check if the requesting user is a sandbox user + requestingUser := &userv1.User{} + err := v.Client.Get(ctx, types.NamespacedName{ + Name: admReview.Request.UserInfo.Username, + }, requestingUser) + + if err != nil { + log.Error(err, "unable to find the user requesting creation of the SSP resource", "username", admReview.Request.UserInfo.Username) + return denyAdmissionRequest(admReview, errors.New("unable to find the user requesting the creation of the SSP resource")) + } + if requestingUser.GetLabels()[toolchainv1alpha1.ProviderLabelKey] == toolchainv1alpha1.ProviderLabelValue { + log.Info("sandbox user is trying to create a SSP", "AdmissionReview", admReview) + return denyAdmissionRequest(admReview, errors.New("this is a Dev Sandbox enforced restriction. you are trying to create a SSP resource, which is not allowed")) + } + // at this point, it is clear the user isn't a sandbox user, allow request + return allowAdmissionRequest(admReview) +} diff --git a/pkg/webhook/validatingwebhook/validate_ssp_request_test.go b/pkg/webhook/validatingwebhook/validate_ssp_request_test.go new file mode 100644 index 00000000..2f17e1c9 --- /dev/null +++ b/pkg/webhook/validatingwebhook/validate_ssp_request_test.go @@ -0,0 +1,180 @@ +package validatingwebhook + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "text/template" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/member-operator/pkg/webhook/validatingwebhook/test" + + userv1 "github.com/openshift/api/user/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestHandleValidateSSPAdmissionRequestBlocked(t *testing.T) { + v := newSSPRequestValidator(t, "johnsmith", true) + // given + ts := httptest.NewServer(http.HandlerFunc(v.HandleValidate)) + defer ts.Close() + + // when + resp, err := http.Post(ts.URL, "application/json", bytes.NewBuffer(newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"CREATE", "johnsmith"}))) + + // then + assert.NoError(t, err) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + assert.NoError(t, err) + test.VerifyRequestBlocked(t, body, "this is a Dev Sandbox enforced restriction. you are trying to create a SSP resource, which is not allowed", "b6ae2ab4-782b-11ee-b962-0242ac120002") +} + +func TestValidateSSPAdmissionRequest(t *testing.T) { + t.Run("sandbox user trying to create a SSP resource is denied", func(t *testing.T) { + // given + v := newSSPRequestValidator(t, "johnsmith", true) + req := newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"CREATE", "johnsmith"}) + + // when + response := v.validate(context.TODO(), req) + + // then + test.VerifyRequestBlocked(t, response, "this is a Dev Sandbox enforced restriction. you are trying to create a SSP resource, which is not allowed", "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + + t.Run("sandbox user trying to update a SSP resource is denied", func(t *testing.T) { + // given + v := newSSPRequestValidator(t, "johnsmith", true) + req := newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"CREATE", "johnsmith"}) + + // when + response := v.validate(context.TODO(), req) + + // then + test.VerifyRequestBlocked(t, response, "this is a Dev Sandbox enforced restriction. you are trying to create a SSP resource, which is not allowed", "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + + t.Run("non-sandbox user trying to create a SSP resource is allowed", func(t *testing.T) { + // given + v := newSSPRequestValidator(t, "other", false) + req := newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"CREATE", "other"}) + + // when + response := v.validate(context.TODO(), req) + + // then + test.VerifyRequestAllowed(t, response, "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + + t.Run("non-sandbox user trying to update a SSP resource is allowed", func(t *testing.T) { + // given + v := newSSPRequestValidator(t, "other", false) + req := newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"UPDATE", "other"}) + + // when + response := v.validate(context.TODO(), req) + + // then + test.VerifyRequestAllowed(t, response, "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + +} + +func newSSPRequestValidator(t *testing.T, username string, isSandboxUser bool) *SSPRequestValidator { + s := scheme.Scheme + err := userv1.Install(s) + require.NoError(t, err) + testUser := &userv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: username, + }, + } + + if isSandboxUser { + testUser.Labels = map[string]string{ + toolchainv1alpha1.ProviderLabelKey: toolchainv1alpha1.ProviderLabelValue, + } + } + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(testUser).Build() + return &SSPRequestValidator{ + Client: cl, + } + +} + +func newCreateSSPAdmissionRequest(t *testing.T, params SSPAdmReviewTmplParams) []byte { + tmpl, err := template.New("admission request").Parse(createSSPJSONTmpl) + require.NoError(t, err) + req := &bytes.Buffer{} + err = tmpl.Execute(req, params) + require.NoError(t, err) + return req.Bytes() +} + +type SSPAdmReviewTmplParams struct { + ReqType string + Username string +} + +var createSSPJSONTmpl = `{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "b6ae2ab4-782b-11ee-b962-0242ac120002", + "kind": { + "group": "ssp.kubevirt.io", + "version": "v1alpha1", + "kind": "SSP" + }, + "resource": { + "group": "ssp.kubevirt.io", + "version": "v1alpha1", + "resource": "ssps" + }, + "requestKind": { + "group": "ssp.kubevirt.io", + "version": "v1alpha1", + "kind": "SSP" + }, + "requestResource": { + "group": "ssp.kubevirt.io", + "version": "v1alpha1", + "resource": "ssps" + }, + "name": "test", + "namespace": "{{.Username}}-dev", + "operation": "{{.ReqType}}", + "userInfo": { + "username": "{{.Username}}", + "groups": [ + "system:authenticated" + ] + }, + "object": { + "apiVersion": "ssp.kubevirt.io", + "kind": "SSP", + "metadata": { + "name": "{{.Username}}", + "namespace": "{{.Username}}-dev" + } + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1", + "fieldManager": "kubectl-client-side-apply", + "fieldValidation": "Ignore" + } + } +}`