-
Notifications
You must be signed in to change notification settings - Fork 38
feat: add managed resource webhook #1152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e72b694
635d5f1
65dd91c
e0f7d89
72babc6
660c99d
c197f14
c35147c
d39c3b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| /* | ||
| Copyright (c) Microsoft Corporation. | ||
| Licensed under the MIT license. | ||
| */ | ||
|
|
||
| package managedresource | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
|
|
||
| admissionv1 "k8s.io/api/admission/v1" | ||
| "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
| "k8s.io/apimachinery/pkg/runtime" | ||
| "k8s.io/apimachinery/pkg/types" | ||
| "k8s.io/klog/v2" | ||
| "sigs.k8s.io/controller-runtime/pkg/manager" | ||
| "sigs.k8s.io/controller-runtime/pkg/webhook" | ||
| "sigs.k8s.io/controller-runtime/pkg/webhook/admission" | ||
|
|
||
| "go.goms.io/fleet/pkg/utils" | ||
| "go.goms.io/fleet/pkg/webhook/validation" | ||
| ) | ||
|
|
||
| const ( | ||
| ManagedByArmKey = "managed-by" | ||
| ManagedByArmValue = "arm" | ||
| deniedResource = "denied admission for managed resource" | ||
| resourceDeniedFormat = "the operation on the managed resource type '%s' name '%s' in namespace '%s' is not allowed" | ||
| ) | ||
|
|
||
| // ValidationPath is the webhook service path which admission requests are routed to. | ||
| var ( | ||
| ValidationPath = fmt.Sprintf(utils.ValidationPathFmt, "arm", "managed", "resources") | ||
| ) | ||
|
|
||
| // Add registers the webhook for K8s bulit-in object types. | ||
| func Add(mgr manager.Manager, whiteListedUsers []string) error { | ||
| hookServer := mgr.GetWebhookServer() | ||
| hookServer.Register(ValidationPath, &webhook.Admission{Handler: &managedResourceValidator{ | ||
| whiteListedUsers: whiteListedUsers, | ||
| }}) | ||
| return nil | ||
| } | ||
|
|
||
| type managedResourceValidator struct { | ||
| whiteListedUsers []string | ||
| } | ||
|
|
||
| // Handle denies the resource admission if the request target object has a label or annotation key "fleet.azure.com". | ||
| func (v *managedResourceValidator) Handle(_ context.Context, req admission.Request) admission.Response { | ||
| namespacedName := types.NamespacedName{Name: req.Name, Namespace: req.Namespace} | ||
| klog.V(1).InfoS("handling resource", "operation", req.Operation, "subResource", req.SubResource, "namespacedName", namespacedName) | ||
|
|
||
| var objs []runtime.RawExtension | ||
| switch req.Operation { | ||
| case admissionv1.Create: | ||
| objs = append(objs, req.Object) | ||
| case admissionv1.Update: | ||
| objs = append(objs, req.Object, req.OldObject) | ||
| case admissionv1.Delete: | ||
| objs = append(objs, req.OldObject) | ||
| } | ||
| for _, obj := range objs { | ||
| labels, annotations, err := getLabelsAndAnnotations(obj) | ||
| if err != nil { | ||
| return admission.Errored(http.StatusInternalServerError, err) | ||
| } | ||
| if (managedByArm(labels) || managedByArm(annotations)) && !validation.IsAdminGroupUserOrWhiteListedUser(v.whiteListedUsers, req.UserInfo) { | ||
| klog.V(2).InfoS(deniedResource, "user", req.UserInfo.Username, "groups", req.UserInfo.Groups, "operation", req.Operation, "GVK", req.RequestKind, "subResource", req.SubResource, "namespacedName", namespacedName) | ||
| return admission.Denied(fmt.Sprintf(resourceDeniedFormat, req.Kind, req.Name, req.Namespace)) | ||
| } | ||
| } | ||
| return admission.Allowed("") | ||
| } | ||
|
|
||
| func getLabelsAndAnnotations(raw runtime.RawExtension) (map[string]string, map[string]string, error) { | ||
| var obj runtime.Object | ||
| if err := runtime.Convert_runtime_RawExtension_To_runtime_Object(&raw, &obj, nil); err != nil { | ||
| return nil, nil, err | ||
| } | ||
| o, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| u := unstructured.Unstructured{Object: o} | ||
|
Comment on lines
+80
to
+87
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We achieve the same using https://github.com/Azure/fleet/blob/main/pkg/webhook/clusterresourceplacement/v1alpha1_clusterresourceplacement_validating_webhook.go#L41. But this can be handled in a follow up PR
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think using a converter doesn't improve much here. It's probably even simpler to not have a decoder just to convert from raw to |
||
| return u.GetLabels(), u.GetAnnotations(), nil | ||
| } | ||
|
|
||
| func managedByArm(m map[string]string) bool { | ||
| if len(m) == 0 { | ||
| return false | ||
| } | ||
| if v, ok := m[ManagedByArmKey]; ok && v == ManagedByArmValue { | ||
| return true | ||
| } | ||
| return false | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| /* | ||
| Copyright (c) Microsoft Corporation. | ||
| Licensed under the MIT license. | ||
| */ | ||
|
|
||
| package managedresource | ||
nwnt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| admissionv1 "k8s.io/api/admission/v1" | ||
| authenticationv1 "k8s.io/api/authentication/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" | ||
| "k8s.io/apimachinery/pkg/runtime" | ||
| "sigs.k8s.io/controller-runtime/pkg/webhook/admission" | ||
| ) | ||
|
|
||
| func Test_managedResourceValidator_Handle(t *testing.T) { | ||
| const fleet1p = "fleet1p" | ||
| validator := &managedResourceValidator{ | ||
| whiteListedUsers: []string{fleet1p}, | ||
| } | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| username string | ||
| operation admissionv1.Operation | ||
| oldLabels map[string]string | ||
| oldAnnotations map[string]string | ||
| newLabels map[string]string | ||
| newAnnotations map[string]string | ||
| expectedResp admission.Response | ||
| modReq func(*admission.Request) | ||
| }{ | ||
| { | ||
| name: "allowed when not managed by arm", | ||
| operation: admissionv1.Update, | ||
| oldLabels: map[string]string{"foo": "bar"}, | ||
| oldAnnotations: map[string]string{"baz": "qux"}, | ||
| newLabels: map[string]string{"foo": "bar"}, | ||
| newAnnotations: map[string]string{"baz": "qux"}, | ||
| expectedResp: admission.Allowed(""), | ||
| }, | ||
| { | ||
| name: "denied - error on getLabelsAndAnnotations failure", | ||
| operation: admissionv1.Create, | ||
| expectedResp: admission.Errored(http.StatusInternalServerError, fmt.Errorf("error decoding string from json: unexpected trailing data at offset 9")), | ||
| modReq: func(req *admission.Request) { | ||
| req.Object = runtime.RawExtension{Raw: []byte(`"invalid"}`)} // Invalid object without labels or annotations | ||
| }, | ||
| }, | ||
| { | ||
| name: "denied - managed by arm in labels, not whitelisted", | ||
| operation: admissionv1.Create, | ||
| oldLabels: nil, | ||
| oldAnnotations: nil, | ||
| newLabels: map[string]string{ManagedByArmKey: ManagedByArmValue}, | ||
| newAnnotations: nil, | ||
| expectedResp: admission.Denied(fmt.Sprintf(resourceDeniedFormat, metav1.GroupVersionKind{Kind: "TestKind"}, "test-resource", "default")), | ||
| }, | ||
| { | ||
| name: "denied - managed by arm in annotations, not whitelisted", | ||
Arvindthiru marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| operation: admissionv1.Update, | ||
| oldLabels: nil, | ||
| oldAnnotations: nil, | ||
| newLabels: nil, | ||
| newAnnotations: map[string]string{ManagedByArmKey: ManagedByArmValue}, | ||
| expectedResp: admission.Denied(fmt.Sprintf(resourceDeniedFormat, metav1.GroupVersionKind{Kind: "TestKind"}, "test-resource", "default")), | ||
| }, | ||
| { | ||
| name: "allowed for other operations", | ||
| operation: admissionv1.Connect, | ||
| oldLabels: nil, | ||
| oldAnnotations: nil, | ||
| newLabels: nil, | ||
| newAnnotations: nil, | ||
| expectedResp: admission.Allowed(""), | ||
| }, | ||
| { | ||
| name: "allowed for other operations - managed by arm, but user whitelisted", | ||
| username: "fleet1p", | ||
| operation: admissionv1.Update, | ||
| oldLabels: map[string]string{"managedBy": ManagedByArmValue}, | ||
| oldAnnotations: nil, | ||
| newLabels: nil, | ||
| newAnnotations: nil, | ||
| expectedResp: admission.Allowed(""), | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| oldObj := makeUnstructured(tt.oldLabels, tt.oldAnnotations) | ||
| newObj := makeUnstructured(tt.newLabels, tt.newAnnotations) | ||
| req := admission.Request{ | ||
| AdmissionRequest: admissionv1.AdmissionRequest{ | ||
| Operation: tt.operation, | ||
| Name: "test-resource", | ||
| Namespace: "default", | ||
| OldObject: runtime.RawExtension{Object: oldObj}, | ||
| Object: runtime.RawExtension{Object: newObj}, | ||
| Kind: metav1.GroupVersionKind{Kind: "TestKind"}, | ||
| RequestKind: &metav1.GroupVersionKind{Kind: "TestKind"}, | ||
| }, | ||
| } | ||
| req.UserInfo = authenticationv1.UserInfo{ | ||
| Username: tt.username, | ||
| } | ||
| if tt.modReq != nil { | ||
| tt.modReq(&req) | ||
| } | ||
| resp := validator.Handle(context.Background(), req) | ||
| if diff := cmp.Diff(tt.expectedResp.Result, resp.Result); diff != "" { | ||
| t.Errorf("managedResourceValidator Handle response (-want +got):\n%s", diff) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func Test_getLabelsAndAnnotations(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| obj runtime.RawExtension | ||
| wantLabels map[string]string | ||
| wantAnnotations map[string]string | ||
| expectError bool | ||
| }{ | ||
| { | ||
| name: "object with labels and annotations", | ||
| obj: runtime.RawExtension{ | ||
| Object: &metav1.PartialObjectMetadata{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Labels: map[string]string{"foo": "bar"}, | ||
| Annotations: map[string]string{"baz": "qux"}, | ||
| }, | ||
| }, | ||
| }, | ||
| wantLabels: map[string]string{"foo": "bar"}, | ||
| wantAnnotations: map[string]string{"baz": "qux"}, | ||
| expectError: false, | ||
| }, | ||
| { | ||
| name: "object with no labels or annotations", | ||
| obj: runtime.RawExtension{ | ||
| Object: &metav1.PartialObjectMetadata{ | ||
| ObjectMeta: metav1.ObjectMeta{}, | ||
| }, | ||
| }, | ||
| wantLabels: nil, | ||
| wantAnnotations: nil, | ||
| expectError: false, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| labels, annotations, err := getLabelsAndAnnotations(tt.obj) | ||
| if err != nil && !tt.expectError { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if diff := cmp.Diff(tt.wantLabels, labels); diff != "" { | ||
| t.Errorf("labels mismatch (-want +got):\n%s", diff) | ||
| } | ||
| if diff := cmp.Diff(tt.wantAnnotations, annotations); diff != "" { | ||
| t.Errorf("annotations mismatch (-want +got):\n%s", diff) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func Test_managedByArm(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| m map[string]string | ||
| want bool | ||
| }{ | ||
| { | ||
| name: "nil map", | ||
| m: nil, | ||
| want: false, | ||
| }, | ||
| { | ||
| name: "empty map", | ||
| m: map[string]string{}, | ||
| want: false, | ||
| }, | ||
| { | ||
| name: "key missing", | ||
| m: map[string]string{"foo": "bar"}, | ||
| want: false, | ||
| }, | ||
| { | ||
| name: "key present, not managed key", | ||
| m: map[string]string{"managingBy": ManagedByArmValue}, | ||
| want: false, | ||
| }, | ||
| { | ||
| name: "key present, not managed value", | ||
| m: map[string]string{ManagedByArmKey: "not-arm"}, | ||
| want: false, | ||
| }, | ||
| { | ||
| name: "key present, managed key and value", | ||
| m: map[string]string{ManagedByArmKey: ManagedByArmValue}, | ||
| want: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if diff := cmp.Diff(tt.want, managedByArm(tt.m)); diff != "" { | ||
| t.Errorf("managedByArm result (-want +got):\n%s", diff) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func makeUnstructured(labels, annotations map[string]string) *unstructured.Unstructured { | ||
| obj := &unstructured.Unstructured{} | ||
| obj.SetLabels(labels) | ||
| obj.SetAnnotations(annotations) | ||
| return obj | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.