Skip to content

Commit

Permalink
Merge pull request #55 from appuio/feat/default-org-validation
Browse files Browse the repository at this point in the history
Implement validating admission webhook for CR users.appuio.io
  • Loading branch information
simu authored Mar 29, 2022
2 parents 929b898 + 26e3901 commit 7b842e1
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 0 deletions.
8 changes: 8 additions & 0 deletions config/webhook/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace: control-api

resources:
- manifests.yaml
- service.yaml

configurations:
- kustomizeconfig.yaml
18 changes: 18 additions & 0 deletions config/webhook/kustomizeconfig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# the following config is for teaching kustomize where to look at when substituting vars.
# It requires kustomize v2.1.0 or newer to work properly.
nameReference:
- kind: Service
version: v1
fieldSpecs:
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/name

namespace:
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true

varReference:
- path: metadata/annotations
27 changes: 27 additions & 0 deletions config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
creationTimestamp: null
name: validating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /validate-appuio-io-v1-user
failurePolicy: Fail
name: validate-users.appuio.io
rules:
- apiGroups:
- appuio.io
apiVersions:
- v1
operations:
- CREATE
- UPDATE
resources:
- users
sideEffects: None
13 changes: 13 additions & 0 deletions config/webhook/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

apiVersion: v1
kind: Service
metadata:
name: webhook-service
namespace: system
spec:
ports:
- port: 443
protocol: TCP
targetPort: 9443
selector:
app: control-api-controller
7 changes: 7 additions & 0 deletions controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/webhook"

"github.com/spf13/cobra"

orgv1 "github.com/appuio/control-api/apis/organization/v1"
controlv1 "github.com/appuio/control-api/apis/v1"

"github.com/appuio/control-api/controllers"
"github.com/appuio/control-api/webhooks"
//+kubebuilder:scaffold:imports
)

Expand Down Expand Up @@ -113,6 +115,11 @@ func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, opt c
return nil, err
}
}

mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{
Handler: &webhooks.UserValidator{},
})

//+kubebuilder:scaffold:builder

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Expand Down
67 changes: 67 additions & 0 deletions webhooks/user_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package webhooks

import (
"context"
"fmt"
"net/http"

"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

controlv1 "github.com/appuio/control-api/apis/v1"
)

// +kubebuilder:webhook:path=/validate-appuio-io-v1-user,mutating=false,failurePolicy=fail,groups="appuio.io",resources=users,verbs=create;update,versions=v1,name=validate-users.appuio.io,admissionReviewVersions=v1,sideEffects=None

// +kubebuilder:rbac:groups=appuio.io,resources=organizationmembers,verbs=get

// UserValidator holds context for the validating admission webhook for users.appuio.io
type UserValidator struct {
client client.Client
decoder *admission.Decoder
}

// Handle handles the users.appuio.io admission requests
func (v *UserValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
log := log.FromContext(ctx).WithName("webhook.validate-users.appuio.io")

user := &controlv1.User{}
if err := v.decoder.Decode(req, user); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
log.V(4).WithValues("user", user).Info("Validating")

orgref := user.Spec.Preferences.DefaultOrganizationRef
orgMembKey := types.NamespacedName{
Name: "members",
Namespace: orgref,
}
orgmemb := &controlv1.OrganizationMembers{}
if err := v.client.Get(ctx, orgMembKey, orgmemb); err != nil {
return admission.Denied(fmt.Sprintf("Unable to load members for organization %s", orgref))
}

log.V(4).WithValues("orgref", orgref, "orgmemb", orgmemb).Info("organizationmembers of requested default organization")

for _, orguser := range orgmemb.Spec.UserRefs {
if user.Name == orguser.Name {
return admission.Allowed("user is member of requested default organization")
}
}

return admission.Denied(fmt.Sprintf("User %s isn't member of organization %s", user.Name, orgref))
}

// InjectDecoder injects a Admission request decoder into the UserValidator
func (v *UserValidator) InjectDecoder(d *admission.Decoder) error {
v.decoder = d
return nil
}

// InjectClient injects a Kubernetes client into the UserValidator
func (v *UserValidator) InjectClient(c client.Client) error {
v.client = c
return nil
}
158 changes: 158 additions & 0 deletions webhooks/user_webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package webhooks

import (
"context"
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

admissionv1 "k8s.io/api/admission/v1"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

orgv1 "github.com/appuio/control-api/apis/organization/v1"
controlv1 "github.com/appuio/control-api/apis/v1"
)

func TestUserValidator_Handle(t *testing.T) {
ctx := context.Background()
tests := map[string]struct {
orgref string
org string
orgmemb []string
allowed bool
errcode int32
}{
"UserIsMember allowed": {
orgref: "test-org",
org: "test-org",
orgmemb: []string{"test-user", "test-user-2"},
allowed: true,
errcode: http.StatusOK,
},
"UserIsNotMember denied": {
orgref: "test-org",
org: "test-org",
orgmemb: []string{"test-user-2", "test-user-3"},
allowed: false,
errcode: http.StatusForbidden,
},
"OrgDoesNotExist denied": {
orgref: "test-org-2",
org: "test-org",
orgmemb: []string{"test-user"},
allowed: false,
errcode: http.StatusForbidden,
},
"InvalidRequest denied": {
orgref: "",
org: "",
orgmemb: []string{"test-user"},
allowed: false,
errcode: http.StatusBadRequest,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
user := controlv1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "test-user",
},
Spec: controlv1.UserSpec{
Preferences: controlv1.UserPreferences{
DefaultOrganizationRef: tc.orgref,
},
},
}

userRefs := []controlv1.UserRef{}
for _, uname := range tc.orgmemb {
userRefs = append(userRefs, controlv1.UserRef{Name: uname})
}
orgmemb := controlv1.OrganizationMembers{
ObjectMeta: metav1.ObjectMeta{
Name: "members",
Namespace: tc.org,
},
Spec: controlv1.OrganizationMembersSpec{
UserRefs: userRefs,
},
}

uv := prepareTest(t, &user, &orgmemb)

userJson, err := json.Marshal(user)
require.NoError(t, err)
// Break admission request object JSON for
// InvalidRequest testcase
if tc.errcode == 400 {
userJson[10] = 'x'
}
userObj := runtime.RawExtension{
Raw: userJson,
}

admissionRequest := admission.Request{
AdmissionRequest: admissionv1.AdmissionRequest{
UID: "e515f52d-7181-494d-a3d3-f0738856bd97",
Kind: metav1.GroupVersionKind{
Group: "appuio.io",
Version: "v1",
Kind: "User",
},
Resource: metav1.GroupVersionResource{
Group: "appuio.io",
Version: "v1",
Resource: "users",
},
Name: "test-user",
Operation: admissionv1.Update,
UserInfo: authenticationv1.UserInfo{
Username: "kubernetes-admin",
Groups: []string{
"system:masters",
"system:authenticated",
},
},
Object: userObj,
},
}

resp := uv.Handle(ctx, admissionRequest)

assert.Equal(t, tc.allowed, resp.Allowed)
assert.Equal(t, tc.errcode, resp.Result.Code)
})
}
}

func prepareTest(t *testing.T, initObjs ...client.Object) *UserValidator {
scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(orgv1.AddToScheme(scheme))
utilruntime.Must(controlv1.AddToScheme(scheme))

decoder, err := admission.NewDecoder(scheme)
require.NoError(t, err)

client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(initObjs...).
Build()

uv := &UserValidator{}
uv.InjectClient(client)
uv.InjectDecoder(decoder)

return uv
}

0 comments on commit 7b842e1

Please sign in to comment.