Skip to content

Commit

Permalink
Add webhook for CNV SSP resources (#527)
Browse files Browse the repository at this point in the history
* Add SSP webhook

* Add tests

* Add more tests
  • Loading branch information
rajivnathan authored Jan 24, 2024
1 parent 73d7db8 commit 69b6581
Show file tree
Hide file tree
Showing 5 changed files with 279 additions and 1 deletion.
4 changes: 4 additions & 0 deletions cmd/webhook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions deploy/webhook/member-operator-webhook.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion pkg/webhook/deploy/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions pkg/webhook/validatingwebhook/validate_ssp_request.go
Original file line number Diff line number Diff line change
@@ -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)
}
180 changes: 180 additions & 0 deletions pkg/webhook/validatingwebhook/validate_ssp_request_test.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
}`

0 comments on commit 69b6581

Please sign in to comment.