Repository: projectcapsule/capsule

Issue:
Sync namespace metadata on creation.
We need to sync namespace metadata (labels, annotation and especially nodeSelector) on namespace creation.
For now, namespace nodeSelector reconciled by controller loop and pod can start before nodeSelector will be set on ns (in case of huge amount of namespaces).

Let's add some extra logic to namespace webhook.

*Have prepared PR for this already.

Hints from issue comments:
@prometherion: I understand you'd like to deploy Pods as quickly as possible. Still, there's another problem: the `RBAC` need a Tenant reconciliation, and I don't see the benefit of burdening webhooks further with logic when the Kubernetes way is the eventual consistency.

Happy to hear more about your use case, tho: I've strong opinions, weakly held.

@oliverbaehler what are your thoughts on this?@CharlieR-o-o-t: The benefits is that pods can started before namespace has node-selector annotation.
We catch these cases on our k8s clusters with high amount of namespaces. Capsule brings nodeSelector after few seconds, during this time lag - pod can be started outside of tenant capacity. 
@oliverbaehler: I am working on something like a `Hook` components. Where you can define mutations, validations at admission or in the reconciler. We will drop our strongly typed spec in favour of that. I am with the use, especially PodNodeSelector is a very weak implementation, but i would rather takkle this in a generic way.@prometherion: > Capsule brings nodeSelector after few seconds, during this time lag - pod can be started outside of tenant capacity.

Rather than pushing more responsibility on the webhooks, we can rearrange the order of reconciliation, giving higher priority to Namespace labels and annotations.

@oliverbaehler WDYT?@oliverbaehler: Happy to implement this one anyway, its a small and reasonable change@applewiskey: We need this one too. Would really help.

Solution patch:
diff --git a/controllers/tenant/namespaces.go b/controllers/tenant/namespaces.go
index 02ee680..408d800 100644
--- a/controllers/tenant/namespaces.go
+++ b/controllers/tenant/namespaces.go
@@ -52,50 +52,8 @@ func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, t
 			return conflictErr
 		}
 
-		capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
-
 		res, conflictErr = controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
-			annotations := buildNamespaceAnnotationsForTenant(tnt)
-			labels := buildNamespaceLabelsForTenant(tnt)
-
-			if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
-				for _, md := range opts.AdditionalMetadataList {
-					ok, err := utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
-					if err != nil {
-						return err
-					}
-
-					if !ok {
-						continue
-					}
-
-					maps.Copy(labels, md.Labels)
-					maps.Copy(annotations, md.Annotations)
-				}
-			}
-
-			labels["kubernetes.io/metadata.name"] = namespace
-			labels[capsuleLabel] = tnt.GetName()
-
-			if tnt.Spec.Cordoned {
-				ns.Labels[utils.CordonedLabel] = "true"
-			} else {
-				delete(ns.Labels, utils.CordonedLabel)
-			}
-
-			if ns.Annotations == nil {
-				ns.SetAnnotations(annotations)
-			} else {
-				maps.Copy(ns.Annotations, annotations)
-			}
-
-			if ns.Labels == nil {
-				ns.SetLabels(labels)
-			} else {
-				maps.Copy(ns.Labels, labels)
-			}
-
-			return nil
+			return SyncNamespaceMetadata(tnt, ns)
 		})
 
 		return conflictErr
@@ -206,3 +164,50 @@ func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2.
 		return
 	})
 }
+
+// SyncNamespaceMetadata sync namespace metadata according to tenant spec.
+func SyncNamespaceMetadata(tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) error {
+	capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
+
+	annotations := buildNamespaceAnnotationsForTenant(tnt)
+	labels := buildNamespaceLabelsForTenant(tnt)
+
+	if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
+		for _, md := range opts.AdditionalMetadataList {
+			ok, err := utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
+			if err != nil {
+				return err
+			}
+
+			if !ok {
+				continue
+			}
+
+			maps.Copy(labels, md.Labels)
+			maps.Copy(annotations, md.Annotations)
+		}
+	}
+
+	labels["kubernetes.io/metadata.name"] = ns.GetName()
+	labels[capsuleLabel] = tnt.GetName()
+
+	if tnt.Spec.Cordoned {
+		ns.Labels[utils.CordonedLabel] = "true"
+	} else {
+		delete(ns.Labels, utils.CordonedLabel)
+	}
+
+	if ns.Annotations == nil {
+		ns.SetAnnotations(annotations)
+	} else {
+		maps.Copy(ns.Annotations, annotations)
+	}
+
+	if ns.Labels == nil {
+		ns.SetLabels(labels)
+	} else {
+		maps.Copy(ns.Labels, labels)
+	}
+
+	return nil
+}
diff --git a/main.go b/main.go
index 69f1c48..5b50c2c 100644
--- a/main.go
+++ b/main.go
@@ -229,7 +229,7 @@ func main() {
 		route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()),
 		route.Cordoning(tenant.CordoningHandler(cfg)),
 		route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
-		route.NamespacePatch(utils.InCapsuleGroups(cfg, namespacemutation.CordoningLabelHandler(cfg), namespacemutation.OwnerReferenceHandler(cfg))),
+		route.NamespacePatch(utils.InCapsuleGroups(cfg, namespacemutation.CordoningLabelHandler(cfg), namespacemutation.OwnerReferenceHandler(cfg), namespacemutation.MetadataHandler(cfg))),
 		route.CustomResources(tenant.ResourceCounterHandler(manager.GetClient())),
 		route.Defaults(defaults.Handler(cfg, kubeVersion)),
 	)
diff --git a/pkg/utils/namespace_selector.go b/pkg/utils/namespace_selector.go
index 46b8aaa..b10679c 100644
--- a/pkg/utils/namespace_selector.go
+++ b/pkg/utils/namespace_selector.go
@@ -1,3 +1,6 @@
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
 package utils
 
 import (
diff --git a/pkg/webhook/namespace/mutation/cordoning.go b/pkg/webhook/namespace/mutation/cordoning.go
index 465067d..4b7bf22 100644
--- a/pkg/webhook/namespace/mutation/cordoning.go
+++ b/pkg/webhook/namespace/mutation/cordoning.go
@@ -6,10 +6,11 @@ package mutation
 import (
 	"context"
 	"encoding/json"
+	"net/http"
+
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/tools/record"
-	"net/http"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
@@ -48,13 +49,16 @@ func (h *cordoningLabelHandler) OnUpdate(c client.Client, decoder admission.Deco
 		if err := decoder.Decode(req, ns); err != nil {
 			return utils.ErroredResponse(err)
 		}
+
 		response := h.syncNamespaceCordonLabel(ctx, c, req, ns)
+
 		return response
 	}
 }
 
 func (h *cordoningLabelHandler) syncNamespaceCordonLabel(ctx context.Context, c client.Client, req admission.Request, ns *corev1.Namespace) *admission.Response {
 	tnt := &capsulev1beta2.Tenant{}
+
 	ln, err := capsuleutils.GetTypeLabel(tnt)
 	if err != nil {
 		response := admission.Errored(http.StatusInternalServerError, err)
@@ -64,7 +68,9 @@ func (h *cordoningLabelHandler) syncNamespaceCordonLabel(ctx context.Context, c
 
 	if label, ok := ns.Labels[ln]; ok {
 		if err = c.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
-			admission.Errored(http.StatusInternalServerError, err)
+			response := admission.Errored(http.StatusInternalServerError, err)
+
+			return &response
 		}
 	}
 
diff --git a/pkg/webhook/namespace/mutation/metadata.go b/pkg/webhook/namespace/mutation/metadata.go
new file mode 100644
index 0000000..fcdbe96
--- /dev/null
+++ b/pkg/webhook/namespace/mutation/metadata.go
@@ -0,0 +1,81 @@
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package mutation
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/tools/record"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	capsuletenant "github.com/projectcapsule/capsule/controllers/tenant"
+	"github.com/projectcapsule/capsule/pkg/configuration"
+	capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
+)
+
+type metadataHandler struct {
+	cfg configuration.Configuration
+}
+
+func MetadataHandler(cfg configuration.Configuration) capsulewebhook.Handler {
+	return &metadataHandler{
+		cfg: cfg,
+	}
+}
+
+func (h *metadataHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
+	return func(ctx context.Context, req admission.Request) *admission.Response {
+		ns := &corev1.Namespace{}
+		if err := decoder.Decode(req, ns); err != nil {
+			response := admission.Errored(http.StatusBadRequest, err)
+
+			return &response
+		}
+
+		tenant, errResponse := getNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
+		if errResponse != nil {
+			return errResponse
+		}
+
+		if tenant == nil {
+			response := admission.Denied("Unable to assign namespace to tenant.")
+
+			return &response
+		}
+
+		// sync namespace metadata
+		if err := capsuletenant.SyncNamespaceMetadata(tenant, ns); err != nil {
+			response := admission.Errored(http.StatusInternalServerError, err)
+
+			return &response
+		}
+
+		marshaled, err := json.Marshal(ns)
+		if err != nil {
+			response := admission.Errored(http.StatusInternalServerError, err)
+
+			return &response
+		}
+
+		response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
+
+		return &response
+	}
+}
+
+func (h *metadataHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
+	return func(context.Context, admission.Request) *admission.Response {
+		return nil
+	}
+}
+
+func (h *metadataHandler) OnUpdate(_ client.Client, _ admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
+	return func(_ context.Context, _ admission.Request) *admission.Response {
+		return nil
+	}
+}
diff --git a/pkg/webhook/namespace/mutation/ownerreference.go b/pkg/webhook/namespace/mutation/ownerreference.go
index fa41b8e..7bd0555 100644
--- a/pkg/webhook/namespace/mutation/ownerreference.go
+++ b/pkg/webhook/namespace/mutation/ownerreference.go
@@ -6,16 +6,12 @@ package mutation
 import (
 	"context"
 	"encoding/json"
-	"fmt"
 	"net/http"
-	"sort"
-	"strings"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/tools/record"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
@@ -143,114 +139,19 @@ func (h *ownerReferenceHandler) setOwnerRef(ctx context.Context, req admission.R
 
 		return &response
 	}
-	// If we already had TenantName label on NS -> assign to it
 
-	if label, ok := ns.Labels[ln]; ok {
-		// retrieving the selected Tenant
-		tnt := &capsulev1beta2.Tenant{}
-		if err = client.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
-			response := admission.Errored(http.StatusBadRequest, err)
-
-			return &response
-		}
-		// Tenant owner must adhere to user that asked for NS creation
-		if !utils.IsTenantOwner(tnt.Spec.Owners, req.UserInfo) {
-			recorder.Eventf(tnt, corev1.EventTypeWarning, "NonOwnedTenant", "Namespace %s cannot be assigned to the current Tenant", ns.GetName())
-
-			response := admission.Denied("Cannot assign the desired namespace to a non-owned Tenant")
-
-			return &response
-		}
-		// Check if namespace needs Tenant name prefix
-		if errResponse := h.validateNamespacePrefix(ns, tnt); errResponse != nil {
-			return errResponse
-		}
-		// Patching the response
-		response := h.patchResponseForOwnerRef(tnt, ns, recorder)
-
-		return &response
+	tnt, errResponse := getNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
+	if errResponse != nil {
+		return errResponse
 	}
 
-	// If we forceTenantPrefix -> find Tenant from NS name
-	var tenants sortedTenants
-
-	// Find tenants belonging to user (it can be regular user or ServiceAccount)
-	if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:") {
-		var tntList *capsulev1beta2.TenantList
-
-		if tntList, err = h.listTenantsForOwnerKind(ctx, "ServiceAccount", req.UserInfo.Username, client); err != nil {
-			response := admission.Errored(http.StatusBadRequest, err)
-
-			return &response
-		}
-
-		for _, tnt := range tntList.Items {
-			tenants = append(tenants, tnt)
-		}
-	} else {
-		var tntList *capsulev1beta2.TenantList
-
-		if tntList, err = h.listTenantsForOwnerKind(ctx, "User", req.UserInfo.Username, client); err != nil {
-			response := admission.Errored(http.StatusBadRequest, err)
-
-			return &response
-		}
-
-		for _, tnt := range tntList.Items {
-			tenants = append(tenants, tnt)
-		}
-	}
-
-	// Find tenants belonging to user groups
-	{
-		for _, group := range req.UserInfo.Groups {
-			tntList, err := h.listTenantsForOwnerKind(ctx, "Group", group, client)
-			if err != nil {
-				response := admission.Errored(http.StatusBadRequest, err)
-
-				return &response
-			}
-
-			for _, tnt := range tntList.Items {
-				tenants = append(tenants, tnt)
-			}
-		}
-	}
-
-	sort.Sort(sort.Reverse(tenants))
-
-	if len(tenants) == 0 {
-		response := admission.Denied("You do not have any Tenant assigned: please, reach out to the system administrators")
+	if tnt == nil {
+		response := admission.Denied("Unable to assign namespace to tenant. Please use " + ln + " label when creating a namespace")
 
 		return &response
 	}
 
-	if len(tenants) == 1 {
-		// Check if namespace needs Tenant name prefix
-		if errResponse := h.validateNamespacePrefix(ns, &tenants[0]); errResponse != nil {
-			return errResponse
-		}
-
-		response := h.patchResponseForOwnerRef(&tenants[0], ns, recorder)
-
-		return &response
-	}
-
-	if h.cfg.ForceTenantPrefix() {
-		for _, tnt := range tenants {
-			if strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) {
-				response := h.patchResponseForOwnerRef(tnt.DeepCopy(), ns, recorder)
-
-				return &response
-			}
-		}
-
-		response := admission.Denied("The Namespace prefix used doesn't match any available Tenant")
-
-		return &response
-	}
-
-	response := admission.Denied("Unable to assign namespace to tenant. Please use " + ln + " label when creating a namespace")
+	response := h.patchResponseForOwnerRef(tnt.DeepCopy(), ns, recorder)
 
 	return &response
 }
@@ -280,40 +181,3 @@ func (h *ownerReferenceHandler) patchResponseForOwnerRef(tenant *capsulev1beta2.
 
 	return admission.PatchResponseFromRaw(o, c)
 }
-
-func (h *ownerReferenceHandler) listTenantsForOwnerKind(ctx context.Context, ownerKind string, ownerName string, clt client.Client) (*capsulev1beta2.TenantList, error) {
-	tntList := &capsulev1beta2.TenantList{}
-	fields := client.MatchingFields{
-		".spec.owner.ownerkind": fmt.Sprintf("%s:%s", ownerKind, ownerName),
-	}
-	err := clt.List(ctx, tntList, fields)
-
-	return tntList, err
-}
-
-func (h *ownerReferenceHandler) validateNamespacePrefix(ns *corev1.Namespace, tenant *capsulev1beta2.Tenant) *admission.Response {
-	// Check if ForceTenantPrefix is true
-	if tenant.Spec.ForceTenantPrefix != nil && *tenant.Spec.ForceTenantPrefix {
-		if !strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tenant.GetName())) {
-			response := admission.Denied(fmt.Sprintf("The Namespace name must start with '%s-' when ForceTenantPrefix is enabled in the Tenant.", tenant.GetName()))
-
-			return &response
-		}
-	}
-
-	return nil
-}
-
-type sortedTenants []capsulev1beta2.Tenant
-
-func (s sortedTenants) Len() int {
-	return len(s)
-}
-
-func (s sortedTenants) Less(i, j int) bool {
-	return len(s[i].GetName()) < len(s[j].GetName())
-}
-
-func (s sortedTenants) Swap(i, j int) {
-	s[i], s[j] = s[j], s[i]
-}
diff --git a/pkg/webhook/namespace/mutation/utils.go b/pkg/webhook/namespace/mutation/utils.go
new file mode 100644
index 0000000..185be3e
--- /dev/null
+++ b/pkg/webhook/namespace/mutation/utils.go
@@ -0,0 +1,207 @@
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package mutation
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"sort"
+	"strings"
+
+	v1 "k8s.io/api/authentication/v1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
+	"github.com/projectcapsule/capsule/pkg/configuration"
+	capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
+	"github.com/projectcapsule/capsule/pkg/webhook/utils"
+)
+
+type sortedTenants []capsulev1beta2.Tenant
+
+func (s sortedTenants) Len() int {
+	return len(s)
+}
+
+func (s sortedTenants) Less(i, j int) bool {
+	return len(s[i].GetName()) < len(s[j].GetName())
+}
+
+func (s sortedTenants) Swap(i, j int) {
+	s[i], s[j] = s[j], s[i]
+}
+
+// getNamespaceTenant returns namespace owner tenant.
+func getNamespaceTenant(
+	ctx context.Context,
+	client client.Client,
+	ns *corev1.Namespace,
+	req admission.Request,
+	cfg configuration.Configuration,
+	recorder record.EventRecorder,
+) (*capsulev1beta2.Tenant, *admission.Response) {
+	tenant, errResponse := getTenantByLabels(ctx, client, ns, req, recorder)
+	if errResponse != nil {
+		return nil, errResponse
+	}
+
+	if tenant == nil {
+		tenant, errResponse = getTenantByUserInfo(ctx, ns, req.UserInfo, client, cfg)
+		if errResponse != nil {
+			return nil, errResponse
+		}
+	}
+
+	return tenant, nil
+}
+
+// getTenantByLabels returns tenant from labels.
+func getTenantByLabels(
+	ctx context.Context,
+	client client.Client,
+	ns *corev1.Namespace,
+	req admission.Request,
+	recorder record.EventRecorder,
+) (*capsulev1beta2.Tenant, *admission.Response) {
+	ln, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{})
+	if err != nil {
+		response := admission.Errored(http.StatusBadRequest, err)
+
+		return nil, &response
+	}
+
+	// Get tenant from namespace labels.
+	if label, ok := ns.Labels[ln]; ok {
+		// retrieving the selected Tenant
+		tnt := &capsulev1beta2.Tenant{}
+		if err = client.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
+			response := admission.Errored(http.StatusBadRequest, err)
+
+			return nil, &response
+		}
+		// Tenant owner must adhere to user that asked for NS creation
+		if !utils.IsTenantOwner(tnt.Spec.Owners, req.UserInfo) {
+			recorder.Eventf(tnt, corev1.EventTypeWarning, "NonOwnedTenant", "Namespace %s cannot be assigned to the current Tenant", ns.GetName())
+
+			response := admission.Denied("Cannot assign the desired namespace to a non-owned Tenant")
+
+			return nil, &response
+		}
+
+		return tnt, nil
+	}
+
+	// Nothing found in the labels.
+	return nil, nil
+}
+
+// getTenantByUserInfo returns tenant list associated with admission request userinfo.
+func getTenantByUserInfo(
+	ctx context.Context,
+	ns *corev1.Namespace,
+	userInfo v1.UserInfo,
+	clt client.Client,
+	cfg configuration.Configuration,
+) (*capsulev1beta2.Tenant, *admission.Response) {
+	var tenants sortedTenants
+
+	// User tenants.
+	userTntList := &capsulev1beta2.TenantList{}
+	fields := client.MatchingFields{
+		".spec.owner.ownerkind": fmt.Sprintf("User:%s", userInfo.Username),
+	}
+
+	err := clt.List(ctx, userTntList, fields)
+	if err != nil {
+		response := admission.Errored(http.StatusBadRequest, err)
+
+		return nil, &response
+	}
+
+	tenants = userTntList.Items
+
+	// ServiceAccount tenants.
+	if strings.HasPrefix(userInfo.Username, "system:serviceaccount:") {
+		saTntList := &capsulev1beta2.TenantList{}
+		fields = client.MatchingFields{
+			".spec.owner.ownerkind": fmt.Sprintf("ServiceAccount:%s", userInfo.Username),
+		}
+
+		err = clt.List(ctx, saTntList, fields)
+		if err != nil {
+			response := admission.Errored(http.StatusBadRequest, err)
+
+			return nil, &response
+		}
+
+		tenants = append(tenants, saTntList.Items...)
+	}
+
+	// Group tenants.
+	groupTntList := &capsulev1beta2.TenantList{}
+
+	for _, group := range userInfo.Groups {
+		fields = client.MatchingFields{
+			".spec.owner.ownerkind": fmt.Sprintf("Group:%s", group),
+		}
+
+		err = clt.List(ctx, groupTntList, fields)
+		if err != nil {
+			response := admission.Errored(http.StatusBadRequest, err)
+
+			return nil, &response
+		}
+
+		tenants = append(tenants, groupTntList.Items...)
+	}
+
+	sort.Sort(sort.Reverse(tenants))
+
+	if len(tenants) == 0 {
+		response := admission.Denied("You do not have any Tenant assigned: please, reach out to the system administrators")
+
+		return nil, &response
+	}
+
+	if len(tenants) == 1 {
+		// Check if namespace needs Tenant name prefix
+		if !validateNamespacePrefix(ns, &tenants[0]) {
+			response := admission.Denied(fmt.Sprintf("The Namespace name must start with '%s-' when ForceTenantPrefix is enabled in the Tenant.", tenants[0].GetName()))
+
+			return nil, &response
+		}
+
+		return &tenants[0], nil
+	}
+
+	if cfg.ForceTenantPrefix() {
+		for _, tnt := range tenants {
+			if strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) {
+				return &tnt, nil
+			}
+		}
+
+		response := admission.Denied("The Namespace prefix used doesn't match any available Tenant")
+
+		return nil, &response
+	}
+
+	return nil, nil
+}
+
+func validateNamespacePrefix(ns *corev1.Namespace, tenant *capsulev1beta2.Tenant) bool {
+	// Check if ForceTenantPrefix is true
+	if tenant.Spec.ForceTenantPrefix != nil && *tenant.Spec.ForceTenantPrefix {
+		if !strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tenant.GetName())) {
+			return false
+		}
+	}
+
+	return true
+}



Test patch:
diff --git a/e2e/namespace_metadata_controller_test.go b/e2e/namespace_metadata_controller_test.go
new file mode 100644
index 0000000..39e99ab
--- /dev/null
+++ b/e2e/namespace_metadata_controller_test.go
@@ -0,0 +1,95 @@
+//go:build e2e
+
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package e2e
+
+import (
+	"context"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
+	"github.com/projectcapsule/capsule/pkg/api"
+)
+
+var _ = Describe("creating a Namespace for a Tenant with additional metadata", func() {
+	tnt := &capsulev1beta2.Tenant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "tenant-metadata",
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: "cap",
+					Kind:       "dummy",
+					Name:       "tenant-metadata",
+					UID:        "tenant-metadata",
+				},
+			},
+		},
+		Spec: capsulev1beta2.TenantSpec{
+			Owners: capsulev1beta2.OwnerListSpec{
+				{
+					Name: "gatsby",
+					Kind: "User",
+				},
+			},
+			NamespaceOptions: &capsulev1beta2.NamespaceOptions{
+				AdditionalMetadata: &api.AdditionalMetadataSpec{
+					Labels: map[string]string{
+						"k8s.io/custom-label":     "foo",
+						"clastix.io/custom-label": "bar",
+					},
+					Annotations: map[string]string{
+						"k8s.io/custom-annotation":     "bizz",
+						"clastix.io/custom-annotation": "buzz",
+					},
+				},
+			},
+		},
+	}
+
+	JustBeforeEach(func() {
+		EventuallyCreation(func() error {
+			return k8sClient.Create(context.TODO(), tnt)
+		}).Should(Succeed())
+	})
+	JustAfterEach(func() {
+		Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
+	})
+
+	It("should contain Namespace metadata after tenant update", func() {
+		ns := NewNamespace("")
+		NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
+		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
+
+		By("checking labels", func() {
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+			Expect(ns.Labels).ShouldNot(HaveKeyWithValue("newlabel", "foobazbar"))
+
+			tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels["newlabel"] = "foobazbar"
+			Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
+
+			Eventually(func() (ok bool) {
+				Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+				ok, _ = Equal(ns.Labels["newlabel"]).Match("foobazbar")
+				return
+			}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
+		})
+		By("checking annotations", func() {
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+			Expect(ns.Labels).ShouldNot(HaveKeyWithValue("newannotation", "foobazbar"))
+
+			tnt.Spec.NamespaceOptions.AdditionalMetadata.Annotations["newannotation"] = "foobazbar"
+			Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
+
+			Eventually(func() (ok bool) {
+				Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+				ok, _ = Equal(ns.Annotations["newannotation"]).Match("foobazbar")
+				return
+			}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
+		})
+	})
+})
\ No newline at end of file
diff --git a/e2e/namespace_metadata_webhook_test.go b/e2e/namespace_metadata_webhook_test.go
new file mode 100644
index 0000000..1ecb0ec
--- /dev/null
+++ b/e2e/namespace_metadata_webhook_test.go
@@ -0,0 +1,94 @@
+//go:build e2e
+
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package e2e
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
+	"github.com/projectcapsule/capsule/pkg/api"
+)
+
+var _ = Describe("creating a Namespace for a Tenant with additional metadata", func() {
+	tnt := &capsulev1beta2.Tenant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "tenant-metadata",
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: "cap",
+					Kind:       "dummy",
+					Name:       "tenant-metadata",
+					UID:        "tenant-metadata",
+				},
+			},
+		},
+		Spec: capsulev1beta2.TenantSpec{
+			Owners: capsulev1beta2.OwnerListSpec{
+				{
+					Name: "gatsby",
+					Kind: "User",
+				},
+			},
+			NodeSelector: map[string]string{
+				"node": "foobar",
+			},
+			NamespaceOptions: &capsulev1beta2.NamespaceOptions{
+				AdditionalMetadata: &api.AdditionalMetadataSpec{
+					Labels: map[string]string{
+						"k8s.io/custom-label":     "foo",
+						"clastix.io/custom-label": "bar",
+					},
+					Annotations: map[string]string{
+						"k8s.io/custom-annotation":     "bizz",
+						"clastix.io/custom-annotation": "buzz",
+					},
+				},
+			},
+		},
+	}
+
+	JustBeforeEach(func() {
+		EventuallyCreation(func() error {
+			return k8sClient.Create(context.TODO(), tnt)
+		}).Should(Succeed())
+	})
+	JustAfterEach(func() {
+		Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
+	})
+
+	It("should contain additional Namespace metadata", func() {
+		ns := NewNamespace("")
+		NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
+		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
+
+		By("checking additional labels", func() {
+				Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+
+				for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels {
+					Expect(ns.Labels).To(HaveKeyWithValue(k, v))
+				}
+				return
+		})
+		By("checking additional annotations", func() {
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+
+			for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Annotations {
+				Expect(ns.Annotations).To(HaveKeyWithValue(k, v))
+			}
+			return
+		})
+		By("checking namespace node-selector annotation", func() {
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+			Expect(ns.Annotations).Should(HaveKeyWithValue("scheduler.alpha.kubernetes.io/node-selector", "node=foobar"))
+			return
+		})
+	})
+})