Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/webhook/add_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"go.goms.io/fleet/pkg/webhook/clusterresourceplacementdisruptionbudget"
"go.goms.io/fleet/pkg/webhook/clusterresourceplacementeviction"
"go.goms.io/fleet/pkg/webhook/fleetresourcehandler"
"go.goms.io/fleet/pkg/webhook/managedresource"
"go.goms.io/fleet/pkg/webhook/membercluster"
"go.goms.io/fleet/pkg/webhook/pod"
"go.goms.io/fleet/pkg/webhook/replicaset"
Expand All @@ -15,6 +16,8 @@ import (
func init() {
// AddToManagerFleetResourceValidator is a function to register fleet guard rail resource validator to the webhook server
AddToManagerFleetResourceValidator = fleetresourcehandler.Add
// AddtoManagerManagedResource is a function to register managed resource validator to the webhook server
AddtoManagerManagedResource = managedresource.Add
// AddToManagerFuncs is a list of functions to register webhook validators to the webhook server
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacement.AddV1Alpha1)
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacement.Add)
Expand Down
99 changes: 99 additions & 0 deletions pkg/webhook/managedresource/managedresource_validating_webhook.go
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
Copy link
Contributor

@Arvindthiru Arvindthiru Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 runtime.Object.

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
}
228 changes: 228 additions & 0 deletions pkg/webhook/managedresource/managedresource_validating_webhook_test.go
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

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",
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
}
8 changes: 4 additions & 4 deletions pkg/webhook/validation/uservalidation.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ var (
func ValidateUserForFleetCRD(req admission.Request, whiteListedUsers []string, group string) admission.Response {
namespacedName := types.NamespacedName{Name: req.Name, Namespace: req.Namespace}
userInfo := req.UserInfo
if checkCRDGroup(group) && !isAdminGroupUserOrWhiteListedUser(whiteListedUsers, userInfo) {
if checkCRDGroup(group) && !IsAdminGroupUserOrWhiteListedUser(whiteListedUsers, userInfo) {
klog.V(2).InfoS(deniedModifyResource, "user", userInfo.Username, "groups", userInfo.Groups, "operation", req.Operation, "GVK", req.RequestKind, "subResource", req.SubResource, "namespacedName", namespacedName)
return admission.Denied(fmt.Sprintf(ResourceDeniedFormat, userInfo.Username, utils.GenerateGroupString(userInfo.Groups), req.Operation, req.RequestKind, req.SubResource, namespacedName))
}
Expand All @@ -63,7 +63,7 @@ func ValidateUserForFleetCRD(req admission.Request, whiteListedUsers []string, g
func ValidateUserForResource(req admission.Request, whiteListedUsers []string) admission.Response {
namespacedName := types.NamespacedName{Name: req.Name, Namespace: req.Namespace}
userInfo := req.UserInfo
if isAdminGroupUserOrWhiteListedUser(whiteListedUsers, userInfo) || isUserAuthenticatedServiceAccount(userInfo) || isUserKubeScheduler(userInfo) || isUserKubeControllerManager(userInfo) || isUserInGroup(userInfo, nodeGroup) || isAKSSupportUser(userInfo) {
if IsAdminGroupUserOrWhiteListedUser(whiteListedUsers, userInfo) || isUserAuthenticatedServiceAccount(userInfo) || isUserKubeScheduler(userInfo) || isUserKubeControllerManager(userInfo) || isUserInGroup(userInfo, nodeGroup) || isAKSSupportUser(userInfo) {
klog.V(3).InfoS(allowedModifyResource, "user", userInfo.Username, "groups", userInfo.Groups, "operation", req.Operation, "GVK", req.RequestKind, "subResource", req.SubResource, "namespacedName", namespacedName)
return admission.Allowed(fmt.Sprintf(ResourceAllowedFormat, userInfo.Username, utils.GenerateGroupString(userInfo.Groups), req.Operation, req.RequestKind, req.SubResource, namespacedName))
}
Expand Down Expand Up @@ -144,10 +144,10 @@ func ValidatedUpstreamMemberClusterUpdate(currentMC, oldMC clusterv1beta1.Member
return admission.Allowed(fmt.Sprintf(ResourceAllowedFormat, userInfo.Username, utils.GenerateGroupString(userInfo.Groups), req.Operation, req.RequestKind, req.SubResource, namespacedName))
}

// isAdminGroupUserOrWhiteListedUser returns true is user belongs to white listed users or user belongs to system:masters/kubeadm:cluster-admins group.
// IsAdminGroupUserOrWhiteListedUser returns true is user belongs to white listed users or user belongs to system:masters/kubeadm:cluster-admins group.
// In clusters using kubeadm, kubernetes-admin belongs to kubeadm:cluster-admins group and kubernetes-super-admin user belongs to system:masters group.
// https://kubernetes.io/docs/reference/setup-tools/kubeadm/implementation-details/#generate-kubeconfig-files-for-control-plane-components
func isAdminGroupUserOrWhiteListedUser(whiteListedUsers []string, userInfo authenticationv1.UserInfo) bool {
func IsAdminGroupUserOrWhiteListedUser(whiteListedUsers []string, userInfo authenticationv1.UserInfo) bool {
return slices.Contains(whiteListedUsers, userInfo.Username) || slices.Contains(userInfo.Groups, mastersGroup) || slices.Contains(userInfo.Groups, kubeadmClusterAdminsGroup)
}

Expand Down
Loading
Loading