// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package validation import ( "context" "fmt" "reflect" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" webhookutils "github.com/projectcapsule/capsule/internal/webhook/utils" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" "github.com/projectcapsule/capsule/pkg/users" ) func NamespaceHandler(configuration configuration.Configuration, hndlers ...handlers.TypedHandlerWithTenantUser[*corev1.Namespace]) handlers.Handler { return &handler{ cfg: configuration, handlers: hndlers, } } type handler struct { cfg configuration.Configuration handlers []handlers.TypedHandlerWithTenantUser[*corev1.Namespace] } func (h *handler) OnCreate( c client.Client, reader client.Reader, decoder admission.Decoder, recorder events.EventRecorder, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { reader = webhookutils.NewTenantCachingReader(reader) user := handlers.ResolveAdmissionUser(ctx, c, req, h.cfg) ns := &corev1.Namespace{} if err := decoder.Decode(req, ns); err != nil { return ad.ErroredResponse(err) } if !user.IsAdmin() && !user.IsCapsule() && !tenant.HasTenantReference(ns) { return nil } tnt, err := tenant.ResolveNamespaceTenant(ctx, reader, ns) if err != nil { return ad.ErroredResponse(err) } if !user.IsAdmin() && !user.IsCapsule() && tnt != nil { return ad.Deny("only tenant owners can create tenant-owned namespaces") } if tnt == nil { return nil } if terminating := h.rejectOnTermination(ns, tnt); terminating != nil { return terminating } for _, hndl := range h.handlers { if response := hndl.OnCreate(c, reader, user, ns, decoder, recorder, tnt)(ctx, req); response != nil { return response } } return nil } } func (h *handler) OnDelete( c client.Client, reader client.Reader, decoder admission.Decoder, recorder events.EventRecorder, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { oldNs := &corev1.Namespace{} if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil { return ad.ErroredResponse(err) } // A namespace which is already terminating must be allowed to complete // deletion even if its Tenant metadata became inconsistent meanwhile. // Kubernetes authorization has already guarded the original delete. if oldNs.DeletionTimestamp != nil || oldNs.Status.Phase == corev1.NamespaceTerminating { return nil } reader = webhookutils.NewTenantCachingReader(reader) tnt, err := tenant.ResolveNamespaceTenant(ctx, reader, oldNs) if apierrors.IsNotFound(err) { // Kubernetes authorization already controls namespace deletion. // A stale Tenant reference must not make a namespace undeletable. return nil } user := handlers.ResolveAdmissionUser(ctx, c, req, h.cfg) if err != nil && !user.IsAdmin() { return ad.ErroredResponse(err) } if tnt == nil { return nil } for _, hndl := range h.handlers { if response := hndl.OnDelete(c, reader, user, oldNs, decoder, recorder, tnt)(ctx, req); response != nil { return response } } return nil } } func (h *handler) OnUpdate( c client.Client, reader client.Reader, decoder admission.Decoder, recorder events.EventRecorder, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { ns := &corev1.Namespace{} if err := decoder.Decode(req, ns); err != nil { return ad.ErroredResponse(err) } oldNs := &corev1.Namespace{} if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil { return ad.ErroredResponse(err) } if response, stop := validateTerminatingNamespaceUpdate(req, oldNs, ns); stop { return response } reader = webhookutils.NewTenantCachingReader(reader) user := handlers.ResolveAdmissionUser(ctx, c, req, h.cfg) if response, stop := validateNamespaceTenantReferenceTransition(user, oldNs, ns); stop { return response } oldTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, oldNs) if err != nil && !user.IsAdmin() { return ad.ErroredResponse(err) } newTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, ns) if err != nil { return ad.ErroredResponse(err) } if !user.IsAdmin() { if oldTenant == nil || newTenant == nil { return ad.Deny("namespace tenant ownership is incomplete") } if oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID() { return ad.Deny("namespace can not be migrated between tenants") } if user.IsCapsule() && !tenant.NamespaceIsOwned(ctx, c, h.cfg, oldNs, oldTenant, user) { recorder.LabeledEvent( ns, corev1.EventTypeWarning, events.ReasonNamespaceHijack, events.ActionValidationDenied, "namespace can not be patched", ). WithRequestAnnotations(req). Emit(ctx) return ad.Deny("denied patch request for this namespace") } } adminTenantTransition := user.IsAdmin() && namespaceTenantChanged(oldTenant, newTenant) tnt := newTenant if !user.IsAdmin() { tnt = oldTenant } if tnt == nil { return nil } if terminating := h.rejectOnTermination(ns, newTenant); terminating != nil { return terminating } if adminTenantTransition { return nil } for _, hndl := range h.handlers { if response := hndl.OnUpdate(c, reader, user, ns, oldNs, decoder, recorder, tnt)(ctx, req); response != nil { return response } } return nil } } func namespaceTenantChanged(oldTenant, newTenant *capsulev1beta2.Tenant) bool { if oldTenant == nil || newTenant == nil { return oldTenant != newTenant } return oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID() } func isTerminatingNamespaceUpdate( req admission.Request, oldNs, newNs *corev1.Namespace, ) bool { return req.SubResource == "finalize" || newNs.DeletionTimestamp != nil || oldNs.DeletionTimestamp != nil || newNs.Status.Phase == corev1.NamespaceTerminating || oldNs.Status.Phase == corev1.NamespaceTerminating } func validateTerminatingNamespaceUpdate( req admission.Request, oldNs, newNs *corev1.Namespace, ) (*admission.Response, bool) { if !isTerminatingNamespaceUpdate(req, oldNs, newNs) { return nil, false } if namespaceTenantAssignmentChanged(oldNs, newNs) { return ad.Deny("namespace tenant ownership can not change during termination"), true } return nil, true } func validateNamespaceTenantReferenceTransition( user users.AdmissionUser, oldNs, newNs *corev1.Namespace, ) (*admission.Response, bool) { if user.IsAdmin() { if !tenant.HasConsistentTenantReference(newNs) { return ad.Deny("tenant label and ownerReference must both be set consistently or both be absent"), true } return nil, false } oldHasTenantReference := tenant.HasTenantReference(oldNs) newHasTenantReference := tenant.HasTenantReference(newNs) switch { case !oldHasTenantReference && newHasTenantReference: return ad.Deny("namespace can not be patched into a tenant"), true case oldHasTenantReference && !newHasTenantReference: return ad.Deny("namespace can not remove tenant ownership"), true case !oldHasTenantReference && !newHasTenantReference: return nil, true default: return nil, false } } func namespaceTenantAssignmentChanged(oldNs, newNs *corev1.Namespace) bool { if tenant.TenanLabelValue(oldNs) != tenant.TenanLabelValue(newNs) { return true } return !reflect.DeepEqual( tenant.TenantOwnerReferences(oldNs), tenant.TenantOwnerReferences(newNs), ) } func (h *handler) rejectOnTermination( ns *corev1.Namespace, t *capsulev1beta2.Tenant, ) *admission.Response { if t == nil { return nil } if t.DeletionTimestamp == nil { return nil } instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{ Name: ns.GetName(), UID: ns.GetUID(), }) if instance != nil { return nil } err := fmt.Errorf("tenant is terminating and does not accept new namespaces") return ad.ErroredResponse(err) }