diff --git a/api/core/v1alpha1/managedcontrolplane_webhook.go b/api/core/v1alpha1/managedcontrolplane_webhook.go index 33f1173..f8cd167 100644 --- a/api/core/v1alpha1/managedcontrolplane_webhook.go +++ b/api/core/v1alpha1/managedcontrolplane_webhook.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" apierrors "k8s.io/apimachinery/pkg/util/errors" ctrl "sigs.k8s.io/controller-runtime" @@ -19,10 +21,34 @@ var managedcontrolplanelog = logf.Log.WithName("managedcontrolplane-resource") func (r *ManagedControlPlane) SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr). For(r). + WithDefaulter(r). WithValidator(r). Complete() } +// +kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-managedcontrolplane,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=managedcontrolplanes,verbs=create;update,versions=v1alpha1,name=vmanagedcontrolplane.kb.io,admissionReviewVersions=v1 + +var _ webhook.CustomDefaulter = &ManagedControlPlane{} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the type +func (r *ManagedControlPlane) Default(ctx context.Context, obj runtime.Object) error { + mcp, ok := obj.(*ManagedControlPlane) + if !ok { + return fmt.Errorf("object not supported") + } + + managedcontrolplanelog.Info("default", "name", mcp.Name) + + req, err := admission.RequestFromContext(ctx) + if err != nil { + return err + } + + setCreatedBy(mcp, req) + + return nil +} + // +kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-managedcontrolplane,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=managedcontrolplanes,verbs=delete,versions=v1alpha1,name=vmanagedcontrolplane.kb.io,admissionReviewVersions=v1 var _ webhook.CustomValidator = &ManagedControlPlane{} @@ -49,7 +75,9 @@ func (r *ManagedControlPlane) ValidateUpdate(_ context.Context, old runtime.Obje var errorList []error // Add update validators here when needed - var updateValidators []func(*ManagedControlPlane, *ManagedControlPlane) error + updateValidators := []func(*ManagedControlPlane, *ManagedControlPlane) error{ + validateCreatedByUnchanged, + } for _, validator := range updateValidators { if err := validator(newMcp, oldMcp); err != nil { @@ -75,3 +103,43 @@ func (r *ManagedControlPlane) ValidateDelete(_ context.Context, obj runtime.Obje } return nil, fmt.Errorf("ManagedControlPlane %q requires annotation %q to be set to true, before it can be deleted", r.Name, ManagedControlPlaneDeletionConfirmationAnnotation) } + +// errCreatedByImmutable is the error that is returned when the value of the resource creator annotation has been changed by the user. +var errCreatedByImmutable = fmt.Errorf("annotation %s is immutable", CreatedByAnnotation) + +// compareStringMapValue compares the value of string values identified by a key in two maps. +// Returns "true" if the value is the same. +func compareStringMapValue(a, b map[string]string, key string) bool { + return a[key] == b[key] +} + +// validateCreatedByUnchanged checks if the value of the annotation that contains the name of the resource creator has been changed. +// Returns an error if the value has been changed or "nil" if it's the same. +func validateCreatedByUnchanged(old, new *ManagedControlPlane) error { + if compareStringMapValue(old.GetAnnotations(), new.GetAnnotations(), CreatedByAnnotation) { + return nil + } + + return errCreatedByImmutable +} + +// setCreatedBy sets an annotation that contains the name of the user who created the resource. +// The value is only set when the "Operation" is "Create". +func setCreatedBy(obj metav1.Object, req admission.Request) { + if req.Operation != admissionv1.Create { + return + } + + setMetaDataAnnotation(obj, CreatedByAnnotation, req.UserInfo.Username) +} + +// setMetaDataAnnotation sets the annotation on the given object. +// If the given Object did not yet have annotations, they are initialized. +func setMetaDataAnnotation(meta metav1.Object, key, value string) { + labels := meta.GetAnnotations() + if labels == nil { + labels = make(map[string]string) + } + labels[key] = value + meta.SetAnnotations(labels) +} diff --git a/api/core/v1alpha1/managedcontrolplane_webhook_test.go b/api/core/v1alpha1/managedcontrolplane_webhook_test.go index 10bfd93..290600b 100644 --- a/api/core/v1alpha1/managedcontrolplane_webhook_test.go +++ b/api/core/v1alpha1/managedcontrolplane_webhook_test.go @@ -3,10 +3,13 @@ package v1alpha1 import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + admissionv1 "k8s.io/api/admission/v1" + authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/uuid" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) var _ = Describe("ManagedControlPlane Webhook", func() { @@ -27,7 +30,7 @@ var _ = Describe("ManagedControlPlane Webhook", func() { Expect(apierrors.IsForbidden(err)).Should(BeTrue()) }) - It("Should admit the deletion if the annoation was set", func() { + It("Should admit the deletion if the annotation was set", func() { var err error namespace := string(uuid.NewUUID()) @@ -124,6 +127,49 @@ var _ = Describe("ManagedControlPlane Webhook", func() { Expect(err).To(HaveOccurred()) }) + It("Should deny to update annotations:openmcp.cloud/created-by", func() { + var err error + + namespace := string(uuid.NewUUID()) + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}) + Expect(err).ShouldNot(HaveOccurred()) + + mcp := &ManagedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mcp", + Namespace: namespace, + }, + } + err = k8sClient.Create(ctx, mcp) + Expect(err).ShouldNot(HaveOccurred()) + + request := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + UserInfo: authv1.UserInfo{ + Username: "john.doe@test.com", + }, + }, + } + + setCreatedBy(mcp, request) + + Expect(mcp.Annotations).Should(Equal(map[string]string{CreatedByAnnotation: "john.doe@test.com"})) + + // doesn't set the CreatedBy annotation if operation is NOT create + request = admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + UserInfo: authv1.UserInfo{ + Username: "jane.doe@test.com", + }, + }, + } + + setCreatedBy(mcp, request) + + Expect(mcp.Annotations).Should(Equal(map[string]string{CreatedByAnnotation: "john.doe@test.com"})) + }) }) }) diff --git a/charts/mcp-operator/templates/rbac.yaml b/charts/mcp-operator/templates/rbac.yaml index fbd0b92..094ceba 100644 --- a/charts/mcp-operator/templates/rbac.yaml +++ b/charts/mcp-operator/templates/rbac.yaml @@ -45,7 +45,7 @@ rules: - apiGroups: ["admissionregistration.k8s.io"] resources: - validatingwebhookconfigurations - # - mutatingwebhookconfigurations for now we are only using validatingwebhooks + - mutatingwebhookconfigurations verbs: ["*"] {{- end }} {{- if not (and .Values.crds .Values.crds.disabled) }}