Repository: clastix/kamaji (go)

Issue:
Add LoadBalancerSourceRanges Field and Validation Logic for TenantControlPlane
In cloud environments, security and network access control are critical concerns, especially for services exposed via LoadBalancer. Without proper access restrictions, services such as API servers could be exposed to unnecessary risks, such as:

Unrestricted Public Access:
- By default, a LoadBalancer service may allow access from any IP address (0.0.0.0/0), which increases the attack surface.

This feature enables:
- Reduced exposure to unauthorized traffic, enhancing the overall security of the cluster.


Pull request:
feat: loadbalancer source range
Closes #610 

Patch:
diff --git a/Makefile b/Makefile
index a1f3284..6b37d89 100644
--- a/Makefile
+++ b/Makefile
@@ -5,6 +5,20 @@
 # - use environment variables to overwrite this value (e.g export VERSION=0.0.2)
 VERSION ?= v1.0.0
 
+# ENVTEST_K8S_VERSION specifies the Kubernetes version to be used 
+# during testing with the envtest environment. This ensures that 
+# the tests run against the correct API and behavior for the 
+# specific Kubernetes release being targeted (v1.31.0 in this case).
+ENVTEST_K8S_VERSION = 1.31.0
+
+# ENVTEST_VERSION defines the version of the setup-envtest binary 
+# used to manage and download the Kubernetes binaries (like etcd, 
+# kube-apiserver, and kubectl) required for testing. This version 
+# ensures compatibility with the selected Kubernetes version and 
+# must align closely with recent releases (release-0.19 is chosen here).
+# Mismatches between these versions could result in compatibility issues.
+ENVTEST_VERSION ?= release-0.19
+
 # Image URL to use all building/pushing image targets
 CONTAINER_REPOSITORY ?= docker.io/clastix/kamaji
 
@@ -34,6 +48,7 @@ HELM           ?= $(LOCALBIN)/helm
 KIND           ?= $(LOCALBIN)/kind
 KO             ?= $(LOCALBIN)/ko
 YQ             ?= $(LOCALBIN)/yq
+ENVTEST        ?= $(LOCALBIN)/setup-envtest
 
 all: build
 
@@ -95,6 +110,11 @@ apidocs-gen: $(APIDOCS_GEN)  ## Download crdoc locally if necessary.
 $(APIDOCS_GEN): $(LOCALBIN)
 	test -s $(LOCALBIN)/crdoc || GOBIN=$(LOCALBIN) go install fybrik.io/crdoc@latest
 
+.PHONY: envtest
+envtest: $(ENVTEST) ## Download envtest-setup locally if necessary.
+$(ENVTEST): $(LOCALBIN)
+	test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION)
+
 ##@ Development
 
 rbac: controller-gen yq
@@ -121,9 +141,10 @@ generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and
 golint: golangci-lint ## Linting the code according to the styling guide.
 	$(GOLANGCI_LINT) run -c .golangci.yml
 
+## Run unit tests (all tests except E2E).
 .PHONY: test
-test: ## Run unit tests (all tests except E2E).
-	@go test \
+test: envtest ginkgo
+	KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -r -v --trace \
 		./api/... \
 		./cmd/... \
 		./internal/... \
diff --git a/api/v1alpha1/tenantcontrolplane_types.go b/api/v1alpha1/tenantcontrolplane_types.go
index 8dc23f4..627d937 100644
--- a/api/v1alpha1/tenantcontrolplane_types.go
+++ b/api/v1alpha1/tenantcontrolplane_types.go
@@ -11,6 +11,14 @@ import (
 
 // NetworkProfileSpec defines the desired state of NetworkProfile.
 type NetworkProfileSpec struct {
+	// LoadBalancerSourceRanges restricts the IP ranges that can access
+	// the LoadBalancer type Service. This field defines a list of IP
+	// address ranges (in CIDR format) that are allowed to access the service.
+	// If left empty, the service will allow traffic from all IP ranges (0.0.0.0/0).
+	// This feature is useful for restricting access to API servers or services
+	// to specific networks for security purposes.
+	// Example: {"192.168.1.0/24", "10.0.0.0/8"}
+	LoadBalancerSourceRanges []string `json:"loadBalancerSourceRanges,omitempty"`
 	// Address where API server of will be exposed.
 	// In case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
 	Address string `json:"address,omitempty"`
@@ -256,6 +264,8 @@ type AddonsSpec struct {
 // TenantControlPlaneSpec defines the desired state of TenantControlPlane.
 // +kubebuilder:validation:XValidation:rule="!has(oldSelf.dataStore) || has(self.dataStore)", message="unsetting the dataStore is not supported"
 // +kubebuilder:validation:XValidation:rule="!has(oldSelf.dataStoreSchema) || has(self.dataStoreSchema)", message="unsetting the dataStoreSchema is not supported"
+// +kubebuilder:validation:XValidation:rule="!has(self.networkProfile.loadBalancerSourceRanges) || (size(self.networkProfile.loadBalancerSourceRanges) == 0 || self.controlPlane.service.serviceType == 'LoadBalancer')", message="LoadBalancer source ranges are supported only with LoadBalancer service type"
+
 type TenantControlPlaneSpec struct {
 	// DataStore allows to specify a DataStore that should be used to store the Kubernetes data for the given Tenant Control Plane.
 	// This parameter is optional and acts as an override over the default one which is used by the Kamaji Operator.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 03485f2..bedff2a 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -1105,6 +1105,11 @@ func (in *KubernetesVersion) DeepCopy() *KubernetesVersion {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *NetworkProfileSpec) DeepCopyInto(out *NetworkProfileSpec) {
 	*out = *in
+	if in.LoadBalancerSourceRanges != nil {
+		in, out := &in.LoadBalancerSourceRanges, &out.LoadBalancerSourceRanges
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 	if in.CertSANs != nil {
 		in, out := &in.CertSANs, &out.CertSANs
 		*out = make([]string, len(*in))
diff --git a/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml b/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml
index 60578b7..db2e850 100644
--- a/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml
+++ b/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml
@@ -66,7 +66,6 @@ spec:
             metadata:
               type: object
             spec:
-              description: TenantControlPlaneSpec defines the desired state of TenantControlPlane.
               properties:
                 addons:
                   description: Addons contain which addons are enabled
@@ -6564,6 +6563,18 @@ spec:
                       items:
                         type: string
                       type: array
+                    loadBalancerSourceRanges:
+                      description: |-
+                        LoadBalancerSourceRanges restricts the IP ranges that can access
+                        the LoadBalancer type Service. This field defines a list of IP
+                        address ranges (in CIDR format) that are allowed to access the service.
+                        If left empty, the service will allow traffic from all IP ranges (0.0.0.0/0).
+                        This feature is useful for restricting access to API servers or services
+                        to specific networks for security purposes.
+                        Example: {"192.168.1.0/24", "10.0.0.0/8"}
+                      items:
+                        type: string
+                      type: array
                     podCidr:
                       default: 10.244.0.0/16
                       description: CIDR for Kubernetes Pods
@@ -6587,6 +6598,8 @@ spec:
                   rule: '!has(oldSelf.dataStore) || has(self.dataStore)'
                 - message: unsetting the dataStoreSchema is not supported
                   rule: '!has(oldSelf.dataStoreSchema) || has(self.dataStoreSchema)'
+                - message: LoadBalancer source ranges are supported only with LoadBalancer service type
+                  rule: '!has(self.networkProfile.loadBalancerSourceRanges) || (size(self.networkProfile.loadBalancerSourceRanges) == 0 || self.controlPlane.service.serviceType == ''LoadBalancer'')'
             status:
               description: TenantControlPlaneStatus defines the observed state of TenantControlPlane.
               properties:
diff --git a/cmd/manager/cmd.go b/cmd/manager/cmd.go
index 9a24d4e..06c8f70 100644
--- a/cmd/manager/cmd.go
+++ b/cmd/manager/cmd.go
@@ -229,6 +229,7 @@ func NewCmd(scheme *runtime.Scheme) *cobra.Command {
 						},
 					},
 					handlers.TenantControlPlaneServiceCIDR{},
+					handlers.TenantControlPlaneLoadBalancerSourceRanges{},
 				},
 				routes.TenantControlPlaneTelemetry{}: {
 					handlers.TenantControlPlaneTelemetry{
diff --git a/docs/content/reference/api.md b/docs/content/reference/api.md
index d9c43a4..a620d47 100644
--- a/docs/content/reference/api.md
+++ b/docs/content/reference/api.md
@@ -782,7 +782,7 @@ TenantControlPlane is the Schema for the tenantcontrolplanes API.
         <td><b><a href="#tenantcontrolplanespec">spec</a></b></td>
         <td>object</td>
         <td>
-          TenantControlPlaneSpec defines the desired state of TenantControlPlane.<br/>
+          <br/>
         </td>
         <td>false</td>
       </tr><tr>
@@ -800,7 +800,7 @@ TenantControlPlane is the Schema for the tenantcontrolplanes API.
 
 
 
-TenantControlPlaneSpec defines the desired state of TenantControlPlane.
+
 
 <table>
     <thead>
@@ -13942,6 +13942,19 @@ Use this field to add additional hostnames when exposing the Tenant Control Plan
             <i>Default</i>: [10.96.0.10]<br/>
         </td>
         <td>false</td>
+      </tr><tr>
+        <td><b>loadBalancerSourceRanges</b></td>
+        <td>[]string</td>
+        <td>
+          LoadBalancerSourceRanges restricts the IP ranges that can access
+the LoadBalancer type Service. This field defines a list of IP
+address ranges (in CIDR format) that are allowed to access the service.
+If left empty, the service will allow traffic from all IP ranges (0.0.0.0/0).
+This feature is useful for restricting access to API servers or services
+to specific networks for security purposes.
+Example: {"192.168.1.0/24", "10.0.0.0/8"}<br/>
+        </td>
+        <td>false</td>
       </tr><tr>
         <td><b>podCidr</b></td>
         <td>string</td>
diff --git a/go.mod b/go.mod
index 4887ff9..8a3074a 100644
--- a/go.mod
+++ b/go.mod
@@ -35,9 +35,9 @@ require (
 	k8s.io/cluster-bootstrap v0.0.0
 	k8s.io/klog/v2 v2.130.1
 	k8s.io/kubelet v0.0.0
-	k8s.io/kubernetes v1.31.1
+	k8s.io/kubernetes v1.31.2
 	k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
-	sigs.k8s.io/controller-runtime v0.19.0
+	sigs.k8s.io/controller-runtime v0.19.1
 )
 
 require (
diff --git a/go.sum b/go.sum
index 3818b2d..aaad9f0 100644
--- a/go.sum
+++ b/go.sum
@@ -591,8 +591,8 @@ k8s.io/kube-proxy v0.31.1 h1:jODw/T0LipsysugosF8JUqGNPiae5DLj3DHrK13iYDk=
 k8s.io/kube-proxy v0.31.1/go.mod h1:u5CXxT6M89GuyPbSTYGzsfuQj1yDoSkqAOvITFfhNXw=
 k8s.io/kubelet v0.31.1 h1:aAxwVxGzbbMKKk/FnSjvkN52K3LdHhjhzmYcyGBuE0c=
 k8s.io/kubelet v0.31.1/go.mod h1:8ZbexYHqUO946gXEfFmnMZiK2UKRGhk7LlGvJ71p2Ig=
-k8s.io/kubernetes v1.31.1 h1:1fcYJe8SAhtannpChbmnzHLwAV9Je99PrGaFtBvCxms=
-k8s.io/kubernetes v1.31.1/go.mod h1:/YGPL//Fb9mdv5vukvAQ7Xon+Bqwry52bmjTdORAw+Q=
+k8s.io/kubernetes v1.31.2 h1:VNSu4O7Xn5FFRsh9ePXyEPg6ucR21fOftarSdi053Gs=
+k8s.io/kubernetes v1.31.2/go.mod h1:9xmT2buyTYj8TRKwRae7FcuY8k5+xlxv7VivvO0KKfs=
 k8s.io/system-validators v1.8.0 h1:tq05tdO9zdJZnNF3SXrq6LE7Knc/KfJm5wk68467JDg=
 k8s.io/system-validators v1.8.0/go.mod h1:gP1Ky+R9wtrSiFbrpEPwWMeYz9yqyy1S/KOh0Vci7WI=
 k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
@@ -601,8 +601,8 @@ mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
 mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
-sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q=
-sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
+sigs.k8s.io/controller-runtime v0.19.1 h1:Son+Q40+Be3QWb+niBXAg2vFiYWolDjjRfO8hn/cxOk=
+sigs.k8s.io/controller-runtime v0.19.1/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
 sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
 sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
 sigs.k8s.io/kustomize/api v0.17.2 h1:E7/Fjk7V5fboiuijoZHgs4aHuexi5Y2loXlVOAVAG5g=
diff --git a/internal/resources/k8s_service_resource.go b/internal/resources/k8s_service_resource.go
index cae7f3f..e592f82 100644
--- a/internal/resources/k8s_service_resource.go
+++ b/internal/resources/k8s_service_resource.go
@@ -102,7 +102,9 @@ func (r *KubernetesServiceResource) mutate(ctx context.Context, tenantControlPla
 		switch tenantControlPlane.Spec.ControlPlane.Service.ServiceType {
 		case kamajiv1alpha1.ServiceTypeLoadBalancer:
 			r.resource.Spec.Type = corev1.ServiceTypeLoadBalancer
-
+			if len(tenantControlPlane.Spec.NetworkProfile.LoadBalancerSourceRanges) > 0 {
+				r.resource.Spec.LoadBalancerSourceRanges = tenantControlPlane.Spec.NetworkProfile.LoadBalancerSourceRanges
+			}
 			if len(address) > 0 {
 				r.resource.Spec.LoadBalancerIP = address
 			}
diff --git a/internal/webhook/handlers/tcp_lb_src_ranges.go b/internal/webhook/handlers/tcp_lb_src_ranges.go
new file mode 100644
index 0000000..ce698be
--- /dev/null
+++ b/internal/webhook/handlers/tcp_lb_src_ranges.go
@@ -0,0 +1,58 @@
+// Copyright 2022 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package handlers
+
+import (
+	"context"
+	"fmt"
+	"net"
+
+	"gomodules.xyz/jsonpatch/v2"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
+	"github.com/clastix/kamaji/internal/webhook/utils"
+)
+
+type TenantControlPlaneLoadBalancerSourceRanges struct{}
+
+func (t TenantControlPlaneLoadBalancerSourceRanges) handle(tcp *kamajiv1alpha1.TenantControlPlane) error {
+	for _, sourceCIDR := range tcp.Spec.NetworkProfile.LoadBalancerSourceRanges {
+		_, _, err := net.ParseCIDR(sourceCIDR)
+		if err != nil {
+			return fmt.Errorf("invalid LoadBalancer source CIDR %s, %s", sourceCIDR, err.Error())
+		}
+	}
+
+	return nil
+}
+
+func (t TenantControlPlaneLoadBalancerSourceRanges) OnCreate(object runtime.Object) AdmissionResponse {
+	return func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) {
+		tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert
+
+		if err := t.handle(tcp); err != nil {
+			return nil, err
+		}
+
+		return nil, nil
+	}
+}
+
+func (t TenantControlPlaneLoadBalancerSourceRanges) OnDelete(runtime.Object) AdmissionResponse {
+	return utils.NilOp()
+}
+
+func (t TenantControlPlaneLoadBalancerSourceRanges) OnUpdate(object runtime.Object, _ runtime.Object) AdmissionResponse {
+	return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) {
+		tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert
+
+		if err := t.handle(tcp); err != nil {
+			return nil, err
+		}
+
+		return nil, nil
+	}
+}


Test patch:
diff --git a/api/v1alpha1/suite_test.go b/api/v1alpha1/suite_test.go
new file mode 100644
index 0000000..34f19b9
--- /dev/null
+++ b/api/v1alpha1/suite_test.go
@@ -0,0 +1,54 @@
+// Copyright 2022 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+package v1alpha1
+
+import (
+	"path/filepath"
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/envtest"
+)
+
+var (
+	cfg       *rest.Config
+	k8sClient client.Client
+	testEnv   *envtest.Environment
+)
+
+func TestAPIs(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "TenantControlPlane Suite")
+}
+
+var _ = BeforeSuite(func() {
+	By("bootstrapping test environment")
+	testEnv = &envtest.Environment{
+		CRDDirectoryPaths: []string{
+			filepath.Join("..", "..", "charts", "kamaji", "crds"),
+			// filepath.Join("../..", "chart", "kamaji", "crds"),
+		},
+	}
+
+	var err error
+	cfg, err = testEnv.Start()
+	Expect(err).ToNot(HaveOccurred())
+	Expect(cfg).ToNot(BeNil())
+
+	err = AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
+	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
+	Expect(err).ToNot(HaveOccurred())
+	Expect(k8sClient).ToNot(BeNil())
+})
+
+var _ = AfterSuite(func() {
+	By("tearing down the test environment")
+	err := testEnv.Stop()
+	Expect(err).ToNot(HaveOccurred())
+})
diff --git a/api/v1alpha1/tenantcontrolplane_types_test.go b/api/v1alpha1/tenantcontrolplane_types_test.go
new file mode 100644
index 0000000..e48f75d
--- /dev/null
+++ b/api/v1alpha1/tenantcontrolplane_types_test.go
@@ -0,0 +1,78 @@
+// Copyright 2022 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package v1alpha1
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+var _ = Describe("Cluster controller", func() {
+	var (
+		ctx context.Context
+		tcp *TenantControlPlane
+	)
+
+	BeforeEach(func() {
+		ctx = context.Background()
+		tcp = &TenantControlPlane{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "tcp",
+				Namespace: "default",
+			},
+			Spec: TenantControlPlaneSpec{},
+		}
+	})
+
+	AfterEach(func() {
+		if err := k8sClient.Delete(ctx, tcp); err != nil && !apierrors.IsNotFound(err) {
+			Expect(err).NotTo(HaveOccurred())
+		}
+	})
+
+	Context("LoadBalancer Source Ranges", func() {
+		It("allows creation when no CIDR ranges are provided", func() {
+			tcp.Spec.ControlPlane.Service.ServiceType = ServiceTypeLoadBalancer
+
+			err := k8sClient.Create(ctx, tcp)
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("allows creation with an explicitly empty CIDR list", func() {
+			tcp.Spec.ControlPlane.Service.ServiceType = ServiceTypeLoadBalancer
+			tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{}
+
+			err := k8sClient.Create(ctx, tcp)
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("allows creation when service type is not LoadBalancer and it has an empty CIDR list", func() {
+			tcp.Spec.ControlPlane.Service.ServiceType = ServiceTypeNodePort
+
+			err := k8sClient.Create(ctx, tcp)
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("allows CIDR ranges when service type is LoadBalancer", func() {
+			tcp.Spec.ControlPlane.Service.ServiceType = ServiceTypeLoadBalancer
+			tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.0.0/24"}
+
+			err := k8sClient.Create(ctx, tcp)
+			Expect(err).NotTo(HaveOccurred())
+		})
+
+		It("denies CIDR ranges when service type is not LoadBalancer", func() {
+			tcp.Spec.ControlPlane.Service.ServiceType = ServiceTypeNodePort
+			tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.0.0/24"}
+
+			err := k8sClient.Create(ctx, tcp)
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("LoadBalancer source ranges are supported only with LoadBalancer service type"))
+		})
+	})
+})
diff --git a/internal/webhook/handlers/tcp_lb_src_ranges_test.go b/internal/webhook/handlers/tcp_lb_src_ranges_test.go
new file mode 100644
index 0000000..7c86d36
--- /dev/null
+++ b/internal/webhook/handlers/tcp_lb_src_ranges_test.go
@@ -0,0 +1,64 @@
+// Copyright 2022 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package handlers_test
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
+	"github.com/clastix/kamaji/internal/webhook/handlers"
+)
+
+var _ = Describe("TCP LoadBalancer Source Ranges Webhook", func() {
+	var (
+		ctx context.Context
+		t   handlers.TenantControlPlaneLoadBalancerSourceRanges
+		tcp *kamajiv1alpha1.TenantControlPlane
+	)
+
+	BeforeEach(func() {
+		t = handlers.TenantControlPlaneLoadBalancerSourceRanges{}
+		tcp = &kamajiv1alpha1.TenantControlPlane{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "tcp",
+				Namespace: "default",
+			},
+			Spec: kamajiv1alpha1.TenantControlPlaneSpec{},
+		}
+		ctx = context.Background()
+	})
+
+	It("allows creation when valid CIDR ranges are provided", func() {
+		tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
+		tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.0.0/24"}
+		_, err := t.OnCreate(tcp)(ctx, admission.Request{})
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	It("allows creation when LoadBalancer service has no CIDR field", func() {
+		tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
+		_, err := t.OnCreate(tcp)(ctx, admission.Request{})
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	It("allows creation when LoadBalancer service has an empty CIDR list", func() {
+		tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
+		tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{}
+		_, err := t.OnCreate(tcp)(ctx, admission.Request{})
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	It("denies creation when source ranges contain invalid CIDRs", func() {
+		tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
+		tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.0.0/33"}
+		_, err := t.OnCreate(tcp)(ctx, admission.Request{})
+		Expect(err).To(HaveOccurred())
+		Expect(err.Error()).To(ContainSubstring("invalid LoadBalancer source CIDR 192.168.0.0/33"))
+	})
+})