From 9d887cd7a7d1125f48854250dfc3c1de1616e924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 10 Dec 2025 06:50:15 +0100 Subject: [PATCH 01/10] fix(controller): decode old object for delete requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- e2e/namespace_status_test.go | 14 ++++++++++++-- internal/webhook/namespace/mutation/handler.go | 2 +- internal/webhook/namespace/validation/handler.go | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/e2e/namespace_status_test.go b/e2e/namespace_status_test.go index b8b3b02d7..61ed51d3b 100644 --- a/e2e/namespace_status_test.go +++ b/e2e/namespace_status_test.go @@ -91,7 +91,12 @@ var _ = Describe("creating namespace with status lifecycle", Label("namespace", }) By("removing first namespace", func() { - Expect(k8sClient.Delete(context.TODO(), ns1)).Should(Succeed()) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + err := cs.CoreV1(). + Namespaces(). + Delete(context.TODO(), ns1.Name, metav1.DeleteOptions{}) + Expect(err).ShouldNot(HaveOccurred()) t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed()) @@ -103,7 +108,12 @@ var _ = Describe("creating namespace with status lifecycle", Label("namespace", }) By("removing second namespace", func() { - Expect(k8sClient.Delete(context.TODO(), ns2)).Should(Succeed()) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + err := cs.CoreV1(). + Namespaces(). + Delete(context.TODO(), ns2.Name, metav1.DeleteOptions{}) + Expect(err).ShouldNot(HaveOccurred()) t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed()) diff --git a/internal/webhook/namespace/mutation/handler.go b/internal/webhook/namespace/mutation/handler.go index 42ac7776d..0ce72b985 100644 --- a/internal/webhook/namespace/mutation/handler.go +++ b/internal/webhook/namespace/mutation/handler.go @@ -73,7 +73,7 @@ func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder } ns := &corev1.Namespace{} - if err := decoder.Decode(req, ns); err != nil { + if err := decoder.DecodeRaw(req.OldObject, ns); err != nil { return utils.ErroredResponse(err) } diff --git a/internal/webhook/namespace/validation/handler.go b/internal/webhook/namespace/validation/handler.go index 41de7fb8b..4b4587e9c 100644 --- a/internal/webhook/namespace/validation/handler.go +++ b/internal/webhook/namespace/validation/handler.go @@ -76,7 +76,7 @@ func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder } ns := &corev1.Namespace{} - if err := decoder.Decode(req, ns); err != nil { + if err := decoder.DecodeRaw(req.OldObject, ns); err != nil { return utils.ErroredResponse(err) } From 221e0e88efead1a7e54652a223cf43fdff510db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 10 Dec 2025 14:49:02 +0100 Subject: [PATCH 02/10] chore: modernize golang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- Makefile | 3 +- api/v1beta1/tenant_types.go | 9 ++-- api/v1beta2/capsuleconfiguration_types.go | 21 ++++++---- api/v1beta2/namespace_options.go | 6 ++- api/v1beta2/resourcepool_status.go | 6 ++- api/v1beta2/resourcepool_types.go | 22 ++++++---- api/v1beta2/resourcepoolclaim_types.go | 22 ++++++---- api/v1beta2/tenant_status.go | 3 +- api/v1beta2/tenant_types.go | 32 ++++++++++----- api/v1beta2/tenantresource_global.go | 18 ++++---- api/v1beta2/tenantresource_namespaced.go | 14 ++++--- charts/capsule/README.md | 2 +- ...sule.clastix.io_capsuleconfigurations.yaml | 7 ++-- ...sule.clastix.io_globaltenantresources.yaml | 3 ++ ...capsule.clastix.io_resourcepoolclaims.yaml | 2 + .../capsule.clastix.io_resourcepools.yaml | 2 + .../capsule.clastix.io_tenantresources.yaml | 2 + .../crds/capsule.clastix.io_tenants.yaml | 6 +++ charts/capsule/values.yaml | 2 +- e2e/namespace_status_test.go | 41 ++++++++++--------- e2e/sa_owner_promotion_test.go | 31 ++++++++------ e2e/suite_test.go | 14 +++---- internal/controllers/resources/processor.go | 37 ++++++----------- internal/controllers/tenant/manager.go | 18 ++------ internal/controllers/tenant/namespaces.go | 8 +++- internal/controllers/utils/predicates.go | 10 ++--- .../webhook/namespace/mutation/handler.go | 30 +------------- .../webhook/namespace/mutation/metadata.go | 7 ++-- .../webhook/namespace/validation/handler.go | 2 - pkg/api/exhaustion.go | 6 ++- pkg/api/owner_list.go | 7 ++-- pkg/api/service_options.go | 6 ++- pkg/template/fast.go | 2 +- pkg/utils/users/is_capsule_user.go | 1 + 34 files changed, 211 insertions(+), 191 deletions(-) diff --git a/Makefile b/Makefile index 1430f9c2a..81081306d 100644 --- a/Makefile +++ b/Makefile @@ -150,6 +150,7 @@ dev-setup: --set 'crds.install=true' \ --set 'crds.exclusive=true'\ --set 'crds.createConfig=true'\ + --set "tls.enableController=false"\ --set "webhooks.exclusive=true"\ --set "webhooks.hooks.nodes.enabled=true"\ --set "webhooks.service.url=$${WEBHOOK_URL}" \ @@ -415,7 +416,7 @@ nwa: $(call go-install-tool,$(NWA),github.com/$(NWA_LOOKUP)@$(NWA_VERSION)) GOLANGCI_LINT := $(LOCALBIN)/golangci-lint -GOLANGCI_LINT_VERSION := v2.5.0 +GOLANGCI_LINT_VERSION := v2.7.2 GOLANGCI_LINT_LOOKUP := golangci/golangci-lint golangci-lint: ## Download golangci-lint locally if necessary. @test -s $(GOLANGCI_LINT) && $(GOLANGCI_LINT) -h | grep -q $(GOLANGCI_LINT_VERSION) || \ diff --git a/api/v1beta1/tenant_types.go b/api/v1beta1/tenant_types.go index 1f53d0d92..89ebdf4fe 100644 --- a/api/v1beta1/tenant_types.go +++ b/api/v1beta1/tenant_types.go @@ -1,6 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 +//nolint:modernize package v1beta1 import ( @@ -20,17 +21,17 @@ type TenantSpec struct { // Specifies the allowed StorageClasses assigned to the Tenant. Capsule assures that all PersistentVolumeClaim resources created in the Tenant can use only one of the allowed StorageClasses. Optional. StorageClasses *api.AllowedListSpec `json:"storageClasses,omitempty"` // Specifies options for the Ingress resources, such as allowed hostnames and IngressClass. Optional. - IngressOptions IngressOptions `json:"ingressOptions,omitempty"` + IngressOptions IngressOptions `json:"ingressOptions"` // Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional. ContainerRegistries *api.AllowedListSpec `json:"containerRegistries,omitempty"` // Specifies the label to control the placement of pods on a given pool of worker nodes. All namespaces created within the Tenant will have the node selector annotation. This annotation tells the Kubernetes scheduler to place pods on the nodes having the selector label. Optional. NodeSelector map[string]string `json:"nodeSelector,omitempty"` // Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional. - NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitempty"` + NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies"` // Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional. - LimitRanges api.LimitRangesSpec `json:"limitRanges,omitempty"` + LimitRanges api.LimitRangesSpec `json:"limitRanges"` // Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional. - ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitempty"` + ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas"` // Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional. AdditionalRoleBindings []api.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"` // Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional. diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 894f0fb1b..f8d35fd73 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -40,13 +40,14 @@ type CapsuleConfigurationSpec struct { // Allows to set different name rather than the canonical one for the Capsule configuration objects, // such as webhook secret or configurations. // +kubebuilder:default={TLSSecretName:"capsule-tls",mutatingWebhookConfigurationName:"capsule-mutating-webhook-configuration",validatingWebhookConfigurationName:"capsule-validating-webhook-configuration"} - CapsuleResources CapsuleResources `json:"overrides,omitempty"` + // +optional + CapsuleResources CapsuleResources `json:"overrides,omitzero"` // Allows to set the forbidden metadata for the worker nodes that could be patched by a Tenant. // This applies only if the Tenant has an active NodeSelector, and the Owner have right to patch their nodes. NodeMetadata *NodeMetadata `json:"nodeMetadata,omitempty"` // Toggles the TLS reconciler, the controller that is able to generate CA and certificates for the webhooks // when not using an already provided CA and certificate, or when these are managed externally with Vault, or cert-manager. - // +kubebuilder:default=true + // +kubebuilder:default=false EnableTLSReconciler bool `json:"enableTLSReconciler"` //nolint:tagliatelle // Define entities which can act as Administrators in the capsule construct // These entities are automatically owners for all existing tenants. Meaning they can add namespaces to any tenant. However they must be specific by using the capsule label @@ -57,9 +58,11 @@ type CapsuleConfigurationSpec struct { type NodeMetadata struct { // Define the labels that a Tenant Owner cannot set for their nodes. - ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels"` + // +optional + ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels,omitzero"` // Define the annotations that a Tenant Owner cannot set for their nodes. - ForbiddenAnnotations api.ForbiddenListSpec `json:"forbiddenAnnotations"` + // +optional + ForbiddenAnnotations api.ForbiddenListSpec `json:"forbiddenAnnotations,omitzero"` } type CapsuleResources struct { @@ -81,10 +84,12 @@ type CapsuleResources struct { // CapsuleConfiguration is the Schema for the Capsule configuration API. type CapsuleConfiguration struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` - Spec CapsuleConfigurationSpec `json:"spec,omitempty"` + Spec CapsuleConfigurationSpec `json:"spec"` } // +kubebuilder:object:root=true @@ -92,7 +97,7 @@ type CapsuleConfiguration struct { // CapsuleConfigurationList contains a list of CapsuleConfiguration. type CapsuleConfigurationList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []CapsuleConfiguration `json:"items"` } diff --git a/api/v1beta2/namespace_options.go b/api/v1beta2/namespace_options.go index cdb19dcbc..2321ff2a4 100644 --- a/api/v1beta2/namespace_options.go +++ b/api/v1beta2/namespace_options.go @@ -18,9 +18,11 @@ type NamespaceOptions struct { // Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant via a list. Optional. AdditionalMetadataList []api.AdditionalMetadataSelectorSpec `json:"additionalMetadataList,omitempty"` // Define the labels that a Tenant Owner cannot set for their Namespace resources. - ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels,omitempty"` + // +optional + ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels,omitzero"` // Define the annotations that a Tenant Owner cannot set for their Namespace resources. - ForbiddenAnnotations api.ForbiddenListSpec `json:"forbiddenAnnotations,omitempty"` + // +optional + ForbiddenAnnotations api.ForbiddenListSpec `json:"forbiddenAnnotations,omitzero"` // If enabled only metadata from additionalMetadata is reconciled to the namespaces. //+kubebuilder:default:=false ManagedMetadataOnly bool `json:"managedMetadataOnly,omitempty"` diff --git a/api/v1beta2/resourcepool_status.go b/api/v1beta2/resourcepool_status.go index f093ee5f2..4c87180c1 100644 --- a/api/v1beta2/resourcepool_status.go +++ b/api/v1beta2/resourcepool_status.go @@ -21,9 +21,11 @@ type ResourcePoolStatus struct { // Namespaces which are considered for claims Namespaces []string `json:"namespaces,omitempty"` // Tracks the quotas for the Resource. - Claims ResourcePoolNamespaceClaimsStatus `json:"claims,omitempty"` + // +optional + Claims ResourcePoolNamespaceClaimsStatus `json:"claims,omitzero"` // Tracks the Usage from Claimed against what has been granted from the pool - Allocation ResourcePoolQuotaStatus `json:"allocation,omitempty"` + // +optional + Allocation ResourcePoolQuotaStatus `json:"allocation,omitzero"` // Exhaustions from claims associated with the pool Exhaustions map[string]api.PoolExhaustionResource `json:"exhaustions,omitempty"` } diff --git a/api/v1beta2/resourcepool_types.go b/api/v1beta2/resourcepool_types.go index bace7a6b6..007a102f1 100644 --- a/api/v1beta2/resourcepool_types.go +++ b/api/v1beta2/resourcepool_types.go @@ -18,10 +18,12 @@ type ResourcePoolSpec struct { Quota corev1.ResourceQuotaSpec `json:"quota"` // The Defaults given for each namespace, the default is not counted towards the total allocation // When you use claims it's recommended to provision Defaults as the prevent the scheduling of any resources - Defaults corev1.ResourceList `json:"defaults,omitempty"` + // +optional + Defaults corev1.ResourceList `json:"defaults,omitzero"` // Additional Configuration //+kubebuilder:default:={} - Config ResourcePoolSpecConfiguration `json:"config,omitempty"` + // +optional + Config ResourcePoolSpecConfiguration `json:"config,omitzero"` } type ResourcePoolSpecConfiguration struct { @@ -55,11 +57,15 @@ type ResourcePoolSpecConfiguration struct { // it's up the group of users within these namespaces, to manage the resources they consume per namespace. Each Resourcepool provisions a ResourceQuotainto all the selected namespaces. Then essentially the ResourcePoolClaims, when they can be assigned to the ResourcePool stack resources on top of that // ResourceQuota based on the namspace, where the ResourcePoolClaim was made from. type ResourcePool struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` - Spec ResourcePoolSpec `json:"spec,omitempty"` - Status ResourcePoolStatus `json:"status,omitempty"` + Spec ResourcePoolSpec `json:"spec"` + + // +optional + Status ResourcePoolStatus `json:"status,omitzero"` } // +kubebuilder:object:root=true @@ -67,7 +73,9 @@ type ResourcePool struct { // ResourcePoolList contains a list of ResourcePool. type ResourcePoolList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + + // +optional + metav1.ListMeta `json:"metadata,omitzero"` Items []ResourcePool `json:"items"` } diff --git a/api/v1beta2/resourcepoolclaim_types.go b/api/v1beta2/resourcepoolclaim_types.go index 85a096f23..bd2542ba9 100644 --- a/api/v1beta2/resourcepoolclaim_types.go +++ b/api/v1beta2/resourcepoolclaim_types.go @@ -22,9 +22,11 @@ type ResourcePoolClaimSpec struct { // ResourceQuotaClaimStatus defines the observed state of ResourceQuotaClaim. type ResourcePoolClaimStatus struct { // Reference to the GlobalQuota being claimed from - Pool api.StatusNameUID `json:"pool,omitempty"` + // +optional + Pool api.StatusNameUID `json:"pool,omitzero"` // Condtion for this resource claim - Condition metav1.Condition `json:"condition,omitempty"` + // +optional + Condition metav1.Condition `json:"condition,omitzero"` } // +kubebuilder:object:root=true @@ -37,11 +39,15 @@ type ResourcePoolClaimStatus struct { // ResourcePoolClaim is the Schema for the resourcepoolclaims API. type ResourcePoolClaim struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` - Spec ResourcePoolClaimSpec `json:"spec,omitempty"` - Status ResourcePoolClaimStatus `json:"status,omitempty"` + Spec ResourcePoolClaimSpec `json:"spec"` + + // +optional + Status ResourcePoolClaimStatus `json:"status,omitzero"` } // +kubebuilder:object:root=true @@ -49,7 +55,9 @@ type ResourcePoolClaim struct { // ResourceQuotaClaimList contains a list of ResourceQuotaClaim. type ResourcePoolClaimList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + + // +optional + metav1.ListMeta `json:"metadata,omitzero"` Items []ResourcePoolClaim `json:"items"` } diff --git a/api/v1beta2/tenant_status.go b/api/v1beta2/tenant_status.go index 848d22cea..576026687 100644 --- a/api/v1beta2/tenant_status.go +++ b/api/v1beta2/tenant_status.go @@ -58,7 +58,8 @@ type TenantStatusNamespaceMetadata struct { type TenantAvailableStatus struct { // Available Class Types within Tenant - Classes TenantAvailableClassesStatus `json:"classes,omitempty"` + // +optional + Classes TenantAvailableClassesStatus `json:"classes,omitzero"` } type TenantAvailableClassesStatus struct { diff --git a/api/v1beta2/tenant_types.go b/api/v1beta2/tenant_types.go index b43482f6b..68958631f 100644 --- a/api/v1beta2/tenant_types.go +++ b/api/v1beta2/tenant_types.go @@ -16,7 +16,8 @@ import ( // TenantSpec defines the desired state of Tenant. type TenantSpec struct { // Specify Permissions for the Tenant. - Permissions Permissions `json:"permissions,omitempty"` + // +optional + Permissions Permissions `json:"permissions,omitzero"` // Specifies the owners of the Tenant. // Optional Owners api.OwnerListSpec `json:"owners,omitempty"` @@ -32,7 +33,8 @@ type TenantSpec struct { // Optional. StorageClasses *api.DefaultAllowedListSpec `json:"storageClasses,omitempty"` // Specifies options for the Ingress resources, such as allowed hostnames and IngressClass. Optional. - IngressOptions IngressOptions `json:"ingressOptions,omitempty"` + // +optional + IngressOptions IngressOptions `json:"ingressOptions,omitzero"` // Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional. ContainerRegistries *api.AllowedListSpec `json:"containerRegistries,omitempty"` // Specifies the label to control the placement of pods on a given pool of worker nodes. All namespaces created within the Tenant will have the node selector annotation. This annotation tells the Kubernetes scheduler to place pods on the nodes having the selector label. Optional. @@ -40,13 +42,16 @@ type TenantSpec struct { // Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/) // // Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional. - NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitempty"` + // +optional + NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitzero"` // Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/) // // Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional. - LimitRanges api.LimitRangesSpec `json:"limitRanges,omitempty"` + // +optional + LimitRanges api.LimitRangesSpec `json:"limitRanges,omitzero"` // Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional. - ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitempty"` + // +optional + ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"` // Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional. AdditionalRoleBindings []api.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"` // Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional. @@ -63,7 +68,8 @@ type TenantSpec struct { // Specifies options for the DeviceClass resources. DeviceClasses *api.SelectorAllowedListSpec `json:"deviceClasses,omitempty"` // Specifies options for the GatewayClass resources. - GatewayOptions GatewayOptions `json:"gatewayOptions,omitempty"` + // +optional + GatewayOptions GatewayOptions `json:"gatewayOptions,omitzero"` // Toggling the Tenant resources cordoning, when enable resources cannot be deleted. //+kubebuilder:default:=false Cordoned bool `json:"cordoned,omitempty"` @@ -110,11 +116,15 @@ func (p *Permissions) ListMatchingOwners( // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" // Tenant is the Schema for the tenants API. type Tenant struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + Spec TenantSpec `json:"spec"` - Spec TenantSpec `json:"spec,omitempty"` - Status TenantStatus `json:"status,omitempty"` + // +optional + Status TenantStatus `json:"status,omitzero"` } func (in *Tenant) GetNamespaces() (res []string) { @@ -130,7 +140,7 @@ func (in *Tenant) GetNamespaces() (res []string) { // TenantList contains a list of Tenant. type TenantList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []Tenant `json:"items"` } diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index 163a9d755..a9addabbf 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -13,7 +13,7 @@ type GlobalTenantResourceSpec struct { TenantResourceSpec `json:",inline"` // Defines the Tenant selector used target the tenants on which resources must be propagated. - TenantSelector metav1.LabelSelector `json:"tenantSelector,omitempty"` + TenantSelector metav1.LabelSelector `json:"tenantSelector,omitzero"` } // GlobalTenantResourceStatus defines the observed state of GlobalTenantResource. @@ -21,7 +21,7 @@ type GlobalTenantResourceStatus struct { // List of Tenants addressed by the GlobalTenantResource. SelectedTenants []string `json:"selectedTenants"` // List of the replicated resources for the given TenantResource. - ProcessedItems ProcessedItems `json:"processedItems"` + ProcessedItems ProcessedItems `json:"processedItems,omitzero"` } type ProcessedItems []ObjectReferenceStatus @@ -42,11 +42,15 @@ func (p *ProcessedItems) AsSet() sets.Set[string] { // GlobalTenantResource allows to propagate resource replications to a specific subset of Tenant resources. type GlobalTenantResource struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + Spec GlobalTenantResourceSpec `json:"spec"` - Spec GlobalTenantResourceSpec `json:"spec,omitempty"` - Status GlobalTenantResourceStatus `json:"status,omitempty"` + // +optional + Status GlobalTenantResourceStatus `json:"status,omitzero"` } // +kubebuilder:object:root=true @@ -54,7 +58,7 @@ type GlobalTenantResource struct { // GlobalTenantResourceList contains a list of GlobalTenantResource. type GlobalTenantResourceList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []GlobalTenantResource `json:"items"` } diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index e3a228588..3151c9282 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -56,11 +56,15 @@ type TenantResourceStatus struct { // The object must be deployed in a Tenant Namespace, and cannot reference object living in non-Tenant namespaces. // For such cases, the GlobalTenantResource must be used. type TenantResource struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + Spec TenantResourceSpec `json:"spec"` - Spec TenantResourceSpec `json:"spec,omitempty"` - Status TenantResourceStatus `json:"status,omitempty"` + // +optional + Status TenantResourceStatus `json:"status,omitzero"` } // +kubebuilder:object:root=true @@ -68,7 +72,7 @@ type TenantResource struct { // TenantResourceList contains a list of TenantResource. type TenantResourceList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []TenantResource `json:"items"` } diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 3270f28c0..72cbb5334 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -122,7 +122,7 @@ The following Values have changed key or Value: | manager.options.generateCertificates | bool | `true` | Specifies whether capsule webhooks certificates should be generated by capsule operator | | manager.options.ignoreUserWithGroups | list | `[]` | Define groups which when found in the request of a user will be ignored by the Capsule this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups. | | manager.options.labels | object | `{}` | Additional labels to add to the CapsuleConfiguration resource | -| manager.options.logLevel | string | `"3"` | Set the log verbosity of the capsule with a value from 1 to 5 | +| manager.options.logLevel | string | `"info"` | Set the log verbosity of the capsule with a value from 1 to 5 | | manager.options.nodeMetadata | object | `{"forbiddenAnnotations":{"denied":[],"deniedRegex":""},"forbiddenLabels":{"denied":[],"deniedRegex":""}}` | Allows to set the forbidden metadata for the worker nodes that could be patched by a Tenant | | manager.options.protectedNamespaceRegex | string | `""` | If specified, disallows creation of namespaces matching the passed regexp | | manager.options.userNames | list | `[]` | DEPRECATED: use users properties. Names of the users considered as Capsule users. | diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 4030bc0d1..2ea5fa117 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -72,7 +72,7 @@ spec: However ServiceAccounts which have been promoted to owner can not promote further serviceAccounts. type: boolean enableTLSReconciler: - default: true + default: false description: |- Toggles the TLS reconciler, the controller that is able to generate CA and certificates for the webhooks when not using an already provided CA and certificate, or when these are managed externally with Vault, or cert-manager. @@ -117,9 +117,6 @@ spec: deniedRegex: type: string type: object - required: - - forbiddenAnnotations - - forbiddenLabels type: object overrides: default: @@ -198,6 +195,8 @@ spec: required: - enableTLSReconciler type: object + required: + - spec type: object served: true storage: true diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index d74c9f001..f8e5a749e 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -249,6 +249,7 @@ spec: required: - resources - resyncPeriod + - tenantSelector type: object status: description: GlobalTenantResourceStatus defines the observed state of @@ -291,6 +292,8 @@ spec: - processedItems - selectedTenants type: object + required: + - spec type: object served: true storage: true diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml index dbfef905e..7dfc2f6f7 100644 --- a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml +++ b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml @@ -151,6 +151,8 @@ spec: type: string type: object type: object + required: + - spec type: object served: true storage: true diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml index 65368c022..80aef6f58 100644 --- a/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml +++ b/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml @@ -321,6 +321,8 @@ spec: type: string type: array type: object + required: + - spec type: object served: true storage: true diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 51009469a..ccddb4a24 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -239,6 +239,8 @@ spec: required: - processedItems type: object + required: + - spec type: object served: true storage: true diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 5319d1f86..e5d5c7f6c 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -1049,7 +1049,11 @@ spec: type: string type: object required: + - ingressOptions + - limitRanges + - networkPolicies - owners + - resourceQuotas type: object status: description: Returns the observed state of the Tenant. @@ -2890,6 +2894,8 @@ spec: - size - state type: object + required: + - spec type: object served: true storage: true diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index e863f02e4..ce22c5a7c 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -177,7 +177,7 @@ manager: # -- Workers (MaxConcurrentReconciles) is the maximum number of concurrent Reconciles which can be run (ALPHA). workers: 1 # -- Set the log verbosity of the capsule with a value from 1 to 5 - logLevel: '3' + logLevel: "info" # -- Define entities which are considered part of the Capsule construct. # Users not mentioned here will be ignored by Capsule users: diff --git a/e2e/namespace_status_test.go b/e2e/namespace_status_test.go index 61ed51d3b..a3db8d9a2 100644 --- a/e2e/namespace_status_test.go +++ b/e2e/namespace_status_test.go @@ -91,29 +91,30 @@ var _ = Describe("creating namespace with status lifecycle", Label("namespace", }) By("removing first namespace", func() { - cs := ownerClient(tnt.Spec.Owners[0].UserSpec) - - err := cs.CoreV1(). - Namespaces(). - Delete(context.TODO(), ns1.Name, metav1.DeleteOptions{}) - Expect(err).ShouldNot(HaveOccurred()) - - t := &capsulev1beta2.Tenant{} - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed()) - - Expect(t.Status.Size).To(Equal(uint(1))) - - instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns1.GetName(), UID: ns1.GetUID()}) - Expect(instance).To(BeNil(), "Namespace instance should be nil") + cs := impersonationClient(tnt.Spec.Owners[0].UserSpec.Name, withDefaultGroups(nil)) + Expect(cs.Delete(context.TODO(), ns1)).Should(Succeed()) + + Eventually(func(g Gomega) { + t := &capsulev1beta2.Tenant{} + + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{Name: tnt.GetName()}, + t, + ) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(t.Status.Size).To(Equal(uint(1))) + + instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{ + Name: ns1.GetName(), + UID: ns1.GetUID(), + }) + g.Expect(instance).To(BeNil(), "Namespace instance should be nil") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) }) By("removing second namespace", func() { - cs := ownerClient(tnt.Spec.Owners[0].UserSpec) - - err := cs.CoreV1(). - Namespaces(). - Delete(context.TODO(), ns2.Name, metav1.DeleteOptions{}) - Expect(err).ShouldNot(HaveOccurred()) + Expect(k8sClient.Delete(context.TODO(), ns2)).Should(Succeed()) t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed()) diff --git a/e2e/sa_owner_promotion_test.go b/e2e/sa_owner_promotion_test.go index 6cfbe637d..09a4d49b1 100644 --- a/e2e/sa_owner_promotion_test.go +++ b/e2e/sa_owner_promotion_test.go @@ -104,9 +104,9 @@ var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label(" client client.Client matcher otypes.GomegaMatcher }{ - "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, - "rb-user": {client: impersonationClient("bob", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, - "rb-sa": {client: impersonationClient("system:serviceaccount:"+sa.GetNamespace()+":default", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, + "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0))), matcher: Not(Succeed())}, + "rb-user": {client: impersonationClient("bob", withDefaultGroups(make([]string, 0))), matcher: Not(Succeed())}, + "rb-sa": {client: impersonationClient("system:serviceaccount:"+sa.GetNamespace()+":default", withDefaultGroups(make([]string, 0))), matcher: Not(Succeed())}, } for name, tc := range personas { @@ -197,9 +197,9 @@ var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label(" client client.Client matcher otypes.GomegaMatcher }{ - "rb-user": {client: impersonationClient("bob", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, - "rb-sa": {client: impersonationClient("system:serviceaccount:"+sa.GetNamespace()+":default", withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Not(Succeed())}, - "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Succeed()}, + "rb-user": {client: impersonationClient("bob", withDefaultGroups(make([]string, 0))), matcher: Not(Succeed())}, + "rb-sa": {client: impersonationClient("system:serviceaccount:"+sa.GetNamespace()+":default", withDefaultGroups(make([]string, 0))), matcher: Not(Succeed())}, + "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0))), matcher: Succeed()}, } for name, tc := range personas { @@ -258,7 +258,7 @@ var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label(" client client.Client matcher otypes.GomegaMatcher }{ - "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0)), k8sClient.Scheme()), matcher: Succeed()}, + "owner": {client: impersonationClient(tnt.Spec.Owners[0].Name, withDefaultGroups(make([]string, 0))), matcher: Succeed()}, } for name, tc := range personas { @@ -294,14 +294,21 @@ var _ = Describe("Promoting ServiceAccounts to Owners", Label("config"), Label(" saClient := impersonationClient( fmt.Sprintf("system:serviceaccount:%s:%s", ns.Name, sa.Name), nil, - k8sClient.Scheme(), ) - newNs := NewNamespace("") - Expect(saClient.Create(context.TODO(), newNs)).To(Succeed()) - TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElements(ns.GetName())) + By("preventing the service account from deleting the namespace", func() { + newNs := NewNamespace("") + Expect(saClient.Create(context.TODO(), newNs)).To(Succeed()) - Expect(saClient.Delete(context.TODO(), newNs)).To(Not(Succeed())) + TenantNamespaceList(tnt, defaultTimeoutInterval). + Should(ContainElements(ns.GetName(), newNs.GetName())) + + Eventually(func(g Gomega) { + // Deletion should eventually be forbidden / fail + g.Expect(saClient.Delete(context.TODO(), newNs)). + ToNot(Succeed()) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) for name, tc := range personas { By(fmt.Sprintf("trying to promote SA as %s", name)) diff --git a/e2e/suite_test.go b/e2e/suite_test.go index 02c81614e..77250708c 100644 --- a/e2e/suite_test.go +++ b/e2e/suite_test.go @@ -12,7 +12,6 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -115,16 +114,17 @@ func ownerClient(owner api.UserSpec) (cs kubernetes.Interface) { return cs } -func impersonationClient(user string, groups []string, scheme *runtime.Scheme) client.Client { - c, err := config.GetConfig() - Expect(err).ToNot(HaveOccurred()) - c.Impersonate = rest.ImpersonationConfig{ +func impersonationClient(user string, groups []string) client.Client { + impersonatedCfg := rest.CopyConfig(cfg) + impersonatedCfg.Impersonate = rest.ImpersonationConfig{ UserName: user, Groups: groups, } - cl, err := client.New(c, client.Options{Scheme: scheme}) + + c, err := client.New(impersonatedCfg, client.Options{Scheme: k8sClient.Scheme()}) Expect(err).ToNot(HaveOccurred()) - return cl + + return c } func withDefaultGroups(groups []string) []string { diff --git a/internal/controllers/resources/processor.go b/internal/controllers/resources/processor.go index cd00a0e31..ab804fdea 100644 --- a/internal/controllers/resources/processor.go +++ b/internal/controllers/resources/processor.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" "sync" "github.com/valyala/fasttemplate" @@ -40,13 +41,8 @@ func prepareAdditionalMetadata(m map[string]string) map[string]string { return make(map[string]string) } - // we need to create a new map to avoid modifying the original one - copied := make(map[string]string, len(m)) - for k, v := range m { - copied[k] = v - } - - return copied + // clone without mutating the original + return maps.Clone(m) } func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set[string]) (updateStatus bool) { @@ -249,12 +245,12 @@ func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant t := fasttemplate.New(template, "{{ ", " }}") - tmplString := t.ExecuteString(map[string]interface{}{ + tmplString := t.ExecuteString(map[string]any{ "tenant.name": tnt.Name, "namespace": ns.Name, }) - obj, keysAndValues := unstructured.Unstructured{}, []interface{}{"index", rawIndex} + obj, keysAndValues := unstructured.Unstructured{}, []any{"index", rawIndex} if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode([]byte(tmplString), nil, &obj); decodeErr != nil { log.Error(decodeErr, "unable to deserialize rawItem", keysAndValues...) @@ -304,27 +300,18 @@ func (r *Processor) createOrUpdate(ctx context.Context, obj *unstructured.Unstru rv := actual.GetResourceVersion() actual.SetUnstructuredContent(desired.Object) - combinedLabels := obj.GetLabels() - if combinedLabels == nil { - combinedLabels = make(map[string]string) - } - - for key, value := range labels { - combinedLabels[key] = value - } + combinedLabels := map[string]string{} + maps.Copy(combinedLabels, obj.GetLabels()) + maps.Copy(combinedLabels, labels) actual.SetLabels(combinedLabels) - combinedAnnotations := obj.GetAnnotations() - if combinedAnnotations == nil { - combinedAnnotations = make(map[string]string) - } - - for key, value := range annotations { - combinedAnnotations[key] = value - } + combinedAnnotations := map[string]string{} + maps.Copy(combinedAnnotations, obj.GetAnnotations()) + maps.Copy(combinedAnnotations, annotations) actual.SetAnnotations(combinedAnnotations) + actual.SetResourceVersion(rv) actual.SetUID(UID) diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index 064a06f96..167a9df67 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -6,6 +6,7 @@ package tenant import ( "context" "fmt" + "slices" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" @@ -155,13 +156,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller q workqueue.TypedRateLimitingInterface[reconcile.Request], ) { r.enqueueForTenantsWithCondition(ctx, e.Object, q, func(tnt *capsulev1beta2.Tenant, c client.Object) bool { - for _, n := range tnt.Status.Namespaces { - if n == c.GetNamespace() { - return true - } - } - - return false + return slices.Contains(tnt.Status.Namespaces, c.GetNamespace()) }) }, UpdateFunc: func( @@ -170,13 +165,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller q workqueue.TypedRateLimitingInterface[reconcile.Request], ) { r.enqueueForTenantsWithCondition(ctx, e.ObjectNew, q, func(tnt *capsulev1beta2.Tenant, c client.Object) bool { - for _, n := range tnt.Status.Namespaces { - if n == c.GetNamespace() { - return true - } - } - - return false + return slices.Contains(tnt.Status.Namespaces, c.GetNamespace()) }) }, DeleteFunc: func( @@ -241,6 +230,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ctrl.Result, err error) { r.Log = r.Log.WithValues("Request.Name", request.Name) + // Fetch the Tenant instance instance := &capsulev1beta2.Tenant{} if err = r.Get(ctx, request.NamespacedName, instance); err != nil { diff --git a/internal/controllers/tenant/namespaces.go b/internal/controllers/tenant/namespaces.go index 6285c2047..814470ce1 100644 --- a/internal/controllers/tenant/namespaces.go +++ b/internal/controllers/tenant/namespaces.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "maps" + "slices" "golang.org/x/sync/errgroup" corev1 "k8s.io/api/core/v1" @@ -213,7 +214,12 @@ func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2. return err } - tenant.AssignNamespaces(list.Items) + // Drop namespaces that are currently being deleted (DeletionTimestamp != nil) + activeNamespaces := slices.DeleteFunc(list.Items, func(ns corev1.Namespace) bool { + return ns.DeletionTimestamp != nil + }) + + tenant.AssignNamespaces(activeNamespaces) return err } diff --git a/internal/controllers/utils/predicates.go b/internal/controllers/utils/predicates.go index df70310a5..a58d6092b 100644 --- a/internal/controllers/utils/predicates.go +++ b/internal/controllers/utils/predicates.go @@ -4,6 +4,8 @@ package utils import ( + "slices" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" @@ -98,12 +100,6 @@ func LabelsChanged(keys []string, oldLabels, newLabels map[string]string) bool { func NamesMatchingPredicate(names ...string) builder.Predicates { return builder.WithPredicates(predicate.NewPredicateFuncs(func(object client.Object) bool { - for _, name := range names { - if object.GetName() == name { - return true - } - } - - return false + return slices.Contains(names, object.GetName()) })) } diff --git a/internal/webhook/namespace/mutation/handler.go b/internal/webhook/namespace/mutation/handler.go index 0ce72b985..fc964f7f1 100644 --- a/internal/webhook/namespace/mutation/handler.go +++ b/internal/webhook/namespace/mutation/handler.go @@ -30,7 +30,6 @@ type handler struct { handlers []webhook.TypedHandler[*corev1.Namespace] } -//nolint:dupl func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators()) @@ -63,35 +62,8 @@ func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder } } -//nolint:dupl func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators()) - - if !userIsAdmin && !users.IsCapsuleUser(ctx, c, h.cfg, req.UserInfo.Username, req.UserInfo.Groups) { - return nil - } - - ns := &corev1.Namespace{} - if err := decoder.DecodeRaw(req.OldObject, ns); err != nil { - return utils.ErroredResponse(err) - } - - tnt, err := tenant.GetTenantByLabels(ctx, c, ns) - if err != nil { - return utils.ErroredResponse(err) - } - - if tnt == nil && userIsAdmin { - return nil - } - - for _, hndl := range h.handlers { - if response := hndl.OnDelete(c, ns, decoder, recorder)(ctx, req); response != nil { - return response - } - } - + return func(context.Context, admission.Request) *admission.Response { return nil } } diff --git a/internal/webhook/namespace/mutation/metadata.go b/internal/webhook/namespace/mutation/metadata.go index f786eb310..28b41ccb9 100644 --- a/internal/webhook/namespace/mutation/metadata.go +++ b/internal/webhook/namespace/mutation/metadata.go @@ -6,6 +6,7 @@ package mutation import ( "context" "encoding/json" + "maps" "net/http" corev1 "k8s.io/api/core/v1" @@ -134,12 +135,10 @@ func mergeStringMap(dst, src map[string]string) map[string]string { } if dst == nil { - dst = make(map[string]string, len(src)) + return maps.Clone(src) } - for k, v := range src { - dst[k] = v - } + maps.Copy(dst, src) return dst } diff --git a/internal/webhook/namespace/validation/handler.go b/internal/webhook/namespace/validation/handler.go index 4b4587e9c..94789893a 100644 --- a/internal/webhook/namespace/validation/handler.go +++ b/internal/webhook/namespace/validation/handler.go @@ -33,7 +33,6 @@ type handler struct { handlers []webhook.TypedHandlerWithTenant[*corev1.Namespace] } -//nolint:dupl func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators()) @@ -66,7 +65,6 @@ func (h *handler) OnCreate(c client.Client, decoder admission.Decoder, recorder } } -//nolint:dupl func (h *handler) OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { userIsAdmin := users.IsAdminUser(req, h.cfg.Administrators()) diff --git a/pkg/api/exhaustion.go b/pkg/api/exhaustion.go index 1ec590530..eb149873f 100644 --- a/pkg/api/exhaustion.go +++ b/pkg/api/exhaustion.go @@ -10,7 +10,9 @@ import ( // +kubebuilder:object:generate=true type PoolExhaustionResource struct { // Available Resources to be claimed - Available resource.Quantity `json:"available,omitempty"` + // +optional + Available resource.Quantity `json:"available,omitzero"` // Requesting Resources - Requesting resource.Quantity `json:"requesting,omitempty"` + // +optional + Requesting resource.Quantity `json:"requesting,omitzero"` } diff --git a/pkg/api/owner_list.go b/pkg/api/owner_list.go index 8c801abc6..dcb3123a9 100644 --- a/pkg/api/owner_list.go +++ b/pkg/api/owner_list.go @@ -4,6 +4,7 @@ package api import ( + "slices" "sort" ) @@ -19,10 +20,8 @@ func (o OwnerListSpec) IsOwner(name string, groups []string) bool { return true } case GroupOwner: - for _, group := range groups { - if group == owner.Name { - return true - } + if slices.Contains(groups, owner.Name) { + return true } } } diff --git a/pkg/api/service_options.go b/pkg/api/service_options.go index 68f52b984..03a4ddf19 100644 --- a/pkg/api/service_options.go +++ b/pkg/api/service_options.go @@ -13,7 +13,9 @@ type ServiceOptions struct { // Specifies the external IPs that can be used in Services with type ClusterIP. An empty list means no IPs are allowed. Optional. ExternalServiceIPs *ExternalServiceIPsSpec `json:"externalIPs,omitempty"` // Define the labels that a Tenant Owner cannot set for their Service resources. - ForbiddenLabels ForbiddenListSpec `json:"forbiddenLabels,omitempty"` + // +optional + ForbiddenLabels ForbiddenListSpec `json:"forbiddenLabels,omitzero"` // Define the annotations that a Tenant Owner cannot set for their Service resources. - ForbiddenAnnotations ForbiddenListSpec `json:"forbiddenAnnotations,omitempty"` + // +optional + ForbiddenAnnotations ForbiddenListSpec `json:"forbiddenAnnotations,omitzero"` } diff --git a/pkg/template/fast.go b/pkg/template/fast.go index 8528246d8..ee6b8e975 100644 --- a/pkg/template/fast.go +++ b/pkg/template/fast.go @@ -20,7 +20,7 @@ func TemplateForTenantAndNamespace(m map[string]string, tnt *capsulev1beta2.Tena } t := fasttemplate.New(v, "{{ ", " }}") - tmplString := t.ExecuteString(map[string]interface{}{ + tmplString := t.ExecuteString(map[string]any{ "tenant.name": tnt.Name, "namespace": ns.Name, }) diff --git a/pkg/utils/users/is_capsule_user.go b/pkg/utils/users/is_capsule_user.go index 1799c21ae..40d2c8b10 100644 --- a/pkg/utils/users/is_capsule_user.go +++ b/pkg/utils/users/is_capsule_user.go @@ -50,6 +50,7 @@ func IsCapsuleUser( } } + //nolint:modernize for _, group := range cfg.UserGroups() { if groupList.Find(group) { if len(cfg.IgnoreUserWithGroups()) > 0 { From 052660e868afd15a5d6654d1b7f4e0aa219d7ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 10 Dec 2025 15:19:07 +0100 Subject: [PATCH 03/10] chore: modernize golang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- api/v1beta1/tenant_types.go | 26 ++++++++++++------- .../crds/capsule.clastix.io_tenants.yaml | 6 ++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/api/v1beta1/tenant_types.go b/api/v1beta1/tenant_types.go index 89ebdf4fe..a33bfa8ce 100644 --- a/api/v1beta1/tenant_types.go +++ b/api/v1beta1/tenant_types.go @@ -1,7 +1,6 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -//nolint:modernize package v1beta1 import ( @@ -21,17 +20,21 @@ type TenantSpec struct { // Specifies the allowed StorageClasses assigned to the Tenant. Capsule assures that all PersistentVolumeClaim resources created in the Tenant can use only one of the allowed StorageClasses. Optional. StorageClasses *api.AllowedListSpec `json:"storageClasses,omitempty"` // Specifies options for the Ingress resources, such as allowed hostnames and IngressClass. Optional. - IngressOptions IngressOptions `json:"ingressOptions"` + // +optional + IngressOptions IngressOptions `json:"ingressOptions,omitzero"` // Specifies the trusted Image Registries assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed trusted registries. Optional. ContainerRegistries *api.AllowedListSpec `json:"containerRegistries,omitempty"` // Specifies the label to control the placement of pods on a given pool of worker nodes. All namespaces created within the Tenant will have the node selector annotation. This annotation tells the Kubernetes scheduler to place pods on the nodes having the selector label. Optional. NodeSelector map[string]string `json:"nodeSelector,omitempty"` // Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional. - NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies"` + // +optional + NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitzero"` // Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional. - LimitRanges api.LimitRangesSpec `json:"limitRanges"` + // +optional + LimitRanges api.LimitRangesSpec `json:"limitRanges,omitzero"` // Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional. - ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas"` + // +optional + ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"` // Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional. AdditionalRoleBindings []api.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"` // Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional. @@ -51,11 +54,13 @@ type TenantSpec struct { // Tenant is the Schema for the tenants API. type Tenant struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` - Spec TenantSpec `json:"spec,omitempty"` - Status TenantStatus `json:"status,omitempty"` + Spec TenantSpec `json:"spec"` + // +optional + Status TenantStatus `json:"status,omitzero"` } func (in *Tenant) Hub() {} @@ -65,7 +70,8 @@ func (in *Tenant) Hub() {} // TenantList contains a list of Tenant. type TenantList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + // +optional + metav1.ListMeta `json:"metadata,omitzero"` Items []Tenant `json:"items"` } diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index e5d5c7f6c..17271817e 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -1049,11 +1049,7 @@ spec: type: string type: object required: - - ingressOptions - - limitRanges - - networkPolicies - owners - - resourceQuotas type: object status: description: Returns the observed state of the Tenant. @@ -1078,6 +1074,8 @@ spec: - size - state type: object + required: + - spec type: object served: true storage: false From 7c418d0b26240cc727d42e94a374b7162c78ad2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 10 Dec 2025 15:21:42 +0100 Subject: [PATCH 04/10] chore: modernize golang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- api/v1beta2/tenantresource_global.go | 1 + .../capsule/crds/capsule.clastix.io_globaltenantresources.yaml | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index a9addabbf..feb98675c 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -13,6 +13,7 @@ type GlobalTenantResourceSpec struct { TenantResourceSpec `json:",inline"` // Defines the Tenant selector used target the tenants on which resources must be propagated. + // +optional TenantSelector metav1.LabelSelector `json:"tenantSelector,omitzero"` } diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index f8e5a749e..136259a8e 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -249,7 +249,6 @@ spec: required: - resources - resyncPeriod - - tenantSelector type: object status: description: GlobalTenantResourceStatus defines the observed state of From b1a45b694978b41dd207f4e8fcf2bba78db94b0a Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Thu, 4 Jun 2026 11:36:10 +0200 Subject: [PATCH 05/10] fix: preserve ca-bundles injected from external providers Signed-off-by: Oliver Baehler --- internal/controllers/admission/mutating.go | 55 ++++++++++++++++++-- internal/controllers/admission/validating.go | 55 ++++++++++++++++++-- internal/controllers/tls/utils.go | 9 +--- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/internal/controllers/admission/mutating.go b/internal/controllers/admission/mutating.go index 0817b4de1..d1f6302fb 100644 --- a/internal/controllers/admission/mutating.go +++ b/internal/controllers/admission/mutating.go @@ -144,14 +144,27 @@ func (r *mutatingReconciler) reconcileConfiguration( obj.SetAnnotations(annotations) + // Preserve existing CA Information (cert-manager) + existingCABundles := mutatingWebhookCABundlesByName(obj.Webhooks) + obj.Webhooks = desiredHooks - caCert, err := tls.FetchCurrentCaBundleForAdmission(ctx, r.client, r.configuration, cfg.Client.CABundle) - if err != nil { - return err + var caCert []byte + + if r.configuration.EnableTLSConfiguration() { + caCert, err = tls.FetchCurrentCaBundleForAdmission(ctx, r.client, r.configuration) + if err != nil { + return err + } + } else { + caCert = cfg.Client.CABundle } - preserveMutatingWebhookCABundles(obj.Webhooks, caCert) + if len(caCert) > 0 { + preserveMutatingWebhookCABundles(obj.Webhooks, caCert) + } else { + restoreMutatingWebhookCABundles(obj.Webhooks, existingCABundles) + } return err }) @@ -222,10 +235,44 @@ func (r *mutatingReconciler) webhooks( return hooks, nil } +func mutatingWebhookCABundlesByName( + hooks []admissionv1.MutatingWebhook, +) map[string][]byte { + out := make(map[string][]byte, len(hooks)) + + for _, hook := range hooks { + if hook.Name == "" || len(hook.ClientConfig.CABundle) == 0 { + continue + } + + out[hook.Name] = append([]byte(nil), hook.ClientConfig.CABundle...) + } + + return out +} + +func restoreMutatingWebhookCABundles( + hooks []admissionv1.MutatingWebhook, + existingCABundles map[string][]byte, +) { + for i := range hooks { + existingCABundle := existingCABundles[hooks[i].Name] + if len(existingCABundle) == 0 { + continue + } + + hooks[i].ClientConfig.CABundle = append([]byte(nil), existingCABundle...) + } +} + func preserveMutatingWebhookCABundles( hooks []admissionv1.MutatingWebhook, caBundle []byte, ) { + if len(caBundle) == 0 { + return + } + for i := range hooks { hooks[i].ClientConfig.CABundle = append([]byte(nil), caBundle...) } diff --git a/internal/controllers/admission/validating.go b/internal/controllers/admission/validating.go index ed33a98f8..22070e01a 100644 --- a/internal/controllers/admission/validating.go +++ b/internal/controllers/admission/validating.go @@ -146,14 +146,27 @@ func (r *validatingReconciler) reconcileValidatingConfiguration( obj.SetAnnotations(annotations) + // Preserve existing CA Information (cert-manager) + existingCABundles := validatingWebhookCABundlesByName(obj.Webhooks) + obj.Webhooks = desiredHooks - caCert, err := tls.FetchCurrentCaBundleForAdmission(ctx, r.client, r.configuration, cfg.Client.CABundle) - if err != nil { - return err + var caCert []byte + + if r.configuration.EnableTLSConfiguration() { + caCert, err = tls.FetchCurrentCaBundleForAdmission(ctx, r.client, r.configuration) + if err != nil { + return err + } + } else { + caCert = cfg.Client.CABundle } - preserveValidatingWebhookCABundles(obj.Webhooks, caCert) + if len(caCert) > 0 { + preserveValidatingWebhookCABundles(obj.Webhooks, caCert) + } else { + restoreValidatingWebhookCABundles(obj.Webhooks, existingCABundles) + } return err }) @@ -224,10 +237,44 @@ func (r *validatingReconciler) validatingWebhooks( return hooks, nil } +func validatingWebhookCABundlesByName( + hooks []admissionv1.ValidatingWebhook, +) map[string][]byte { + out := make(map[string][]byte, len(hooks)) + + for _, hook := range hooks { + if hook.Name == "" || len(hook.ClientConfig.CABundle) == 0 { + continue + } + + out[hook.Name] = append([]byte(nil), hook.ClientConfig.CABundle...) + } + + return out +} + +func restoreValidatingWebhookCABundles( + hooks []admissionv1.ValidatingWebhook, + existingCABundles map[string][]byte, +) { + for i := range hooks { + existingCABundle := existingCABundles[hooks[i].Name] + if len(existingCABundle) == 0 { + continue + } + + hooks[i].ClientConfig.CABundle = append([]byte(nil), existingCABundle...) + } +} + func preserveValidatingWebhookCABundles( hooks []admissionv1.ValidatingWebhook, caBundle []byte, ) { + if len(caBundle) == 0 { + return + } + for i := range hooks { hooks[i].ClientConfig.CABundle = append([]byte(nil), caBundle...) } diff --git a/internal/controllers/tls/utils.go b/internal/controllers/tls/utils.go index 79adbeb64..f8c37309e 100644 --- a/internal/controllers/tls/utils.go +++ b/internal/controllers/tls/utils.go @@ -141,13 +141,7 @@ func FetchCurrentCaBundleForAdmission( ctx context.Context, c client.Reader, cfg configuration.Configuration, - configuredCABundle []byte, ) ([]byte, error) { - // Explicit configuration wins. - if len(configuredCABundle) > 0 { - return append([]byte(nil), configuredCABundle...), nil - } - // Internal Capsule TLS enabled: source of truth is the TLS Secret. if cfg.EnableTLSConfiguration() { secret := &corev1.Secret{} @@ -175,7 +169,6 @@ func FetchCurrentCaBundleForAdmission( return append([]byte(nil), caBundle...), nil } - // cert-manager / external injector mode: - // return nil and preserve current webhook caBundle. + // TLS Controller not enabled return nil, nil } From 9c28a0baffccf4ac10e6dcad886f207d924ea504 Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Wed, 24 Jun 2026 06:49:12 +0200 Subject: [PATCH 06/10] feat(rules): add service enforcement rules Signed-off-by: Oliver Baehler --- Makefile | 16 +- charts/capsule/README.md | 9 + .../crds/capsule.clastix.io_rulestatuses.yaml | 281 +++ .../crds/capsule.clastix.io_tenants.yaml | 94 + charts/capsule/templates/configuration.yaml | 42 + charts/capsule/values.schema.json | 35 + charts/capsule/values.yaml | 21 + cmd/controller/main.go | 17 +- e2e/namespace_metadata_controller_test.go | 2 +- e2e/rules_enforce_services_test.go | 1208 ++++++++++++ .../distro/capsule/example-setup/tenants.yaml | 18 +- internal/controllers/admission/mutating.go | 3 +- internal/controllers/admission/validating.go | 3 +- .../customquotas/custom_quota_controller.go | 5 +- .../global_custom_quota_controller.go | 5 +- internal/controllers/pv/controller.go | 5 +- .../resourcepools/claim_controller.go | 7 +- .../resourcepools/pool_controller.go | 7 +- internal/controllers/resources/global.go | 7 +- internal/controllers/resources/namespaced.go | 7 +- internal/controllers/rulestatus/manager.go | 38 +- internal/controllers/tenant/manager.go | 3 +- internal/controllers/tenantowner/manager.go | 1 + internal/controllers/utils/options.go | 25 +- internal/webhook/route/rules.go | 30 + .../rules/pods/validation/factory_test.go | 12 + internal/webhook/rules/pods/validation/qos.go | 4 + .../webhook/rules/pods/validation/qos_test.go | 466 +++++ .../webhook/rules/pods/validation/registry.go | 26 +- .../rules/pods/validation/registry_test.go | 1157 +++++++++++ .../rules/pods/validation/schedulers.go | 3 + .../rules/pods/validation/schedulers_test.go | 521 +++++ .../services/validation/external_name.go | 86 + .../services/validation/external_name_test.go | 531 +++++ .../rules/services/validation/factory.go | 180 ++ .../rules/services/validation/factory_test.go | 12 + .../rules/services/validation/loadbalancer.go | 204 ++ .../services/validation/loadbalancer_test.go | 903 +++++++++ .../rules/services/validation/node_port.go | 134 ++ .../services/validation/node_port_test.go | 756 ++++++++ .../rules/services/validation/service_type.go | 61 + .../services/validation/service_type_test.go | 436 +++++ .../rules/services/validation/utils.go | 105 + .../rules/services/validation/utils_test.go | 528 +++++ internal/webhook/rules/status/validation.go | 80 + internal/webhook/rules/utils.go | 34 + internal/webhook/service/handler.go | 4 +- internal/webhook/service/validating.go | 6 +- .../tenant/validation/rule_validator.go | 50 +- pkg/api/forbidden_list.go | 16 +- pkg/api/forbidden_list_test.go | 45 + pkg/api/rules/enforce_services_types.go | 78 + pkg/api/rules/enforce_types.go | 4 + pkg/api/rules/zz_generated.deepcopy.go | 113 ++ pkg/ruleengine/convert_test.go | 194 ++ pkg/ruleengine/enforce_evaluator.go | 172 +- pkg/ruleengine/enforce_evaluator_test.go | 1727 ++++++----------- pkg/ruleengine/validate.go | 183 ++ pkg/ruleengine/validate_test.go | 360 ++++ pkg/runtime/events/reasons.go | 3 +- svc.yaml | 13 + 61 files changed, 9787 insertions(+), 1309 deletions(-) create mode 100644 e2e/rules_enforce_services_test.go create mode 100644 internal/webhook/route/rules.go create mode 100644 internal/webhook/rules/pods/validation/factory_test.go create mode 100644 internal/webhook/rules/pods/validation/qos_test.go create mode 100644 internal/webhook/rules/pods/validation/registry_test.go create mode 100644 internal/webhook/rules/pods/validation/schedulers_test.go create mode 100644 internal/webhook/rules/services/validation/external_name.go create mode 100644 internal/webhook/rules/services/validation/external_name_test.go create mode 100644 internal/webhook/rules/services/validation/factory.go create mode 100644 internal/webhook/rules/services/validation/factory_test.go create mode 100644 internal/webhook/rules/services/validation/loadbalancer.go create mode 100644 internal/webhook/rules/services/validation/loadbalancer_test.go create mode 100644 internal/webhook/rules/services/validation/node_port.go create mode 100644 internal/webhook/rules/services/validation/node_port_test.go create mode 100644 internal/webhook/rules/services/validation/service_type.go create mode 100644 internal/webhook/rules/services/validation/service_type_test.go create mode 100644 internal/webhook/rules/services/validation/utils.go create mode 100644 internal/webhook/rules/services/validation/utils_test.go create mode 100644 internal/webhook/rules/status/validation.go create mode 100644 internal/webhook/rules/utils.go create mode 100644 pkg/api/rules/enforce_services_types.go create mode 100644 pkg/ruleengine/convert_test.go create mode 100644 pkg/ruleengine/validate.go create mode 100644 pkg/ruleengine/validate_test.go create mode 100644 svc.yaml diff --git a/Makefile b/Makefile index abd01142c..136af5a6c 100644 --- a/Makefile +++ b/Makefile @@ -48,8 +48,13 @@ all: manager # Run tests .PHONY: test -test: test-clean generate manifests test-clean - @GO111MODULE=on go test -race -v $(shell go list ./... | grep -v "e2e") -coverprofile coverage.out +test: gotestsum test-clean generate manifests test-clean + @GO111MODULE=on $(GOTEST) \ + --format pkgname-and-test-fails \ + --packages="$(shell go list ./... | grep -v "e2e")" \ + -- \ + -race \ + -coverprofile coverage.out .PHONY: test-clean test-clean: ## Clean tests cache @@ -680,6 +685,13 @@ syft: ## Download syft locally if necessary. test -s $(SYFT) && $(SYFT) --version | grep -q $(SYFT_VERSION) || \ $(call go-install-tool,$(SYFT),github.com/$(SYFT_LOOKUP)/cmd/syft@v$(SYFT_VERSION)) +GOTEST := $(LOCALBIN)/gotestsum +GOTEST_VERSION := 1.13.0 +GOTEST_LOOKUP := gotestyourself/gotestsum +gotestsum: + test -s $(GOTEST) && $(GOTEST) --version | grep -q $(GOTEST_VERSION) || \ + $(call go-install-tool,$(GOTEST),gotest.tools/gotestsum@v$(GOTEST_VERSION)) + HARPOON := $(LOCALBIN)/harpoon HARPOON_VERSION := v0.10.2 HARPOON_LOOKUP := alegrey91/harpoon diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 018e745f7..84b0c9a2c 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -274,6 +274,7 @@ The following Values have changed key or Value: | webhooks.hooks.managed.objectSelector | object | `{"matchExpressions":[{"key":"projectcapsule.dev/managed-by","operator":"In","values":["controller"]}]}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.managed.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.managed.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE","DELETE"],"resources":["*"],"scope":"Namespaced"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | +| webhooks.hooks.metadata | object | `{"enabled":true,"failurePolicy":"Ignore","matchConditions":[{"expression":"!has(request.subResource) || request.subResource == \"\"","name":"ignore-subresources"},{"expression":"request.resource.resource != \"events\"","name":"ignore-events"}],"matchPolicy":"Equivalent","namespaceSelector":{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]},"objectSelector":{},"opts":{},"reinvocationPolicy":"Never","rules":[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE"],"resources":["*"],"scope":"Namespaced"}]}` | Additional Metadata webhook | | webhooks.hooks.metadata.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.metadata.failurePolicy | string | `"Ignore"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.metadata.matchConditions | list | `[{"expression":"!has(request.subResource) || request.subResource == \"\"","name":"ignore-subresources"},{"expression":"request.resource.resource != \"events\"","name":"ignore-events"}]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | @@ -347,6 +348,14 @@ The following Values have changed key or Value: | webhooks.hooks.resourcepools.pools.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.resourcepools.pools.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.resourcepools.pools.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) | +| webhooks.hooks.rulestatus | object | `{"enabled":false,"failurePolicy":"Fail","matchConditions":[],"matchPolicy":"Equivalent","namespaceSelector":{},"objectSelector":{},"rules":[]}` | Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) | +| webhooks.hooks.rulestatus.enabled | bool | `false` | Enable the Hook | +| webhooks.hooks.rulestatus.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | +| webhooks.hooks.rulestatus.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.rulestatus.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.rulestatus.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | +| webhooks.hooks.rulestatus.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | +| webhooks.hooks.rulestatus.rules | list | `[]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | | webhooks.hooks.serviceaccounts.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.serviceaccounts.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.serviceaccounts.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index 0045207cc..d2219ac49 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -68,6 +68,99 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints for + nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: @@ -247,6 +340,100 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints + for nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: @@ -362,6 +549,100 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints + for nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 41737437c..ce26495d8 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2515,6 +2515,100 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints + for nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: diff --git a/charts/capsule/templates/configuration.yaml b/charts/capsule/templates/configuration.yaml index da5597bbf..7f05d3af7 100644 --- a/charts/capsule/templates/configuration.yaml +++ b/charts/capsule/templates/configuration.yaml @@ -108,6 +108,48 @@ spec: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} + {{- with .Values.webhooks.hooks.rulestatus }} + {{- if .enabled }} + {{- $any = true }} + - name: rulestatus.validating.projectcapsule.dev + {{- with .opts }} + opts: + {{- toYaml . | nindent 10 }} + {{- end }} + admissionReviewVersions: + - v1 + - v1beta1 + path: "/rulestatus/validating" + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 10 }} + {{- end }} + rules: + - apiGroups: + - capsule.clastix.io + apiVersions: + - v1beta2 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - rulestatuses + scope: 'Namespaced' + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} + {{- end }} + {{- end }} {{- with .Values.webhooks.hooks.customresources }} {{- if .enabled }} {{- $any = true }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index a7c798806..5f64e34bf 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -1566,6 +1566,7 @@ } }, "metadata": { + "description": "Additional Metadata webhook", "type": "object", "properties": { "enabled": { @@ -2068,6 +2069,40 @@ } } }, + "rulestatus": { + "description": "Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission))", + "type": "object", + "properties": { + "enabled": { + "description": "Enable the Hook", + "type": "boolean" + }, + "failurePolicy": { + "description": "[FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)", + "type": "string" + }, + "matchConditions": { + "description": "[MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "array" + }, + "matchPolicy": { + "description": "[MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "string" + }, + "namespaceSelector": { + "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", + "type": "object" + }, + "objectSelector": { + "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", + "type": "object" + }, + "rules": { + "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", + "type": "array" + } + } + }, "serviceaccounts": { "type": "object", "properties": { diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index fb47320e8..0f4d52220 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -584,6 +584,27 @@ webhooks: # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) rules: [] + # -- Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) + rulestatus: + # -- Enable the Hook + enabled: false + # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) + failurePolicy: Fail + # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchPolicy: Equivalent + # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) + objectSelector: {} + # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) + namespaceSelector: {} + # matchExpressions: + # - key: capsule.clastix.io/tenant + # operator: Exists + # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchConditions: [] + # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) + rules: [] + + # -- Additional Metadata webhook metadata: # -- Enable the Hook enabled: true diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 47884a224..216e59177 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" goRuntime "runtime" + "time" flag "github.com/spf13/pflag" _ "go.uber.org/automaxprocs" @@ -75,6 +76,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/resourcepool" "github.com/projectcapsule/capsule/internal/webhook/route" podrules "github.com/projectcapsule/capsule/internal/webhook/rules/pods/validation" + servicerules "github.com/projectcapsule/capsule/internal/webhook/rules/services/validation" "github.com/projectcapsule/capsule/internal/webhook/service" "github.com/projectcapsule/capsule/internal/webhook/serviceaccounts" tenantmutation "github.com/projectcapsule/capsule/internal/webhook/tenant/mutation" @@ -127,6 +129,8 @@ func main() { clientConnectionBurst int32 webhookPort int + + cacheSyncTimeout time.Duration ) var goFlagSet goflag.FlagSet @@ -148,11 +152,17 @@ func main() { "Enabling this will ensure there is only one active controller manager.", ) flag.IntVar( - &controllerConfig.MaxConcurrentReconciles, + &controllerConfig.Runtime.MaxConcurrentReconciles, "workers", 1, "MaxConcurrentReconciles is the maximum number of concurrent Reconciles which can be run.", ) + flag.DurationVar( + &cacheSyncTimeout, + "cache-sync-timeout", + 0, + "The timeout used when waiting for controller cache synchronization. If unset or 0, the controller-runtime default is used.", + ) flag.StringVar( &metricsAddr, "metrics-addr", @@ -272,6 +282,10 @@ func main() { os.Exit(1) } + if cacheSyncTimeout > 0 { + controllerConfig.Runtime.CacheSyncTimeout = cacheSyncTimeout + } + if len(controllerConfig.ConfigurationName) == 0 { setupLog.Error(fmt.Errorf("missing CapsuleConfiguration resource name"), "unable to start manager") os.Exit(1) @@ -583,6 +597,7 @@ func main() { ), route.Service( service.Handler( + servicerules.ServiceRules(regexCache), service.Validating(), ), ), diff --git a/e2e/namespace_metadata_controller_test.go b/e2e/namespace_metadata_controller_test.go index 28e076ed4..7b26975eb 100644 --- a/e2e/namespace_metadata_controller_test.go +++ b/e2e/namespace_metadata_controller_test.go @@ -21,7 +21,7 @@ import ( var _ = Describe("creating a Namespace for a Tenant with additional metadata", Ordered, Label("namespace", "metadata"), func() { tnt := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ - Name: "e2e-tenant-metadata", + Name: "e2e-tenant-ns-metadata", Labels: map[string]string{ "env": "e2e", }, diff --git a/e2e/rules_enforce_services_test.go b/e2e/rules_enforce_services_test.go new file mode 100644 index 000000000..2833f97db --- /dev/null +++ b/e2e/rules_enforce_services_test.go @@ -0,0 +1,1208 @@ +// Copyright 2020-2026 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + 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/intstr" + "k8s.io/client-go/kubernetes" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "rules", "enforce", "services"), func() { + const ownerName = "e2e-rules-services" + + var ( + tnt *capsulev1beta2.Tenant + tenantRules []*rules.NamespaceRuleBodyTenant + ) + + externalNameByExpression := func(expression string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + ExpressionRegex: capsuleapi.ExpressionRegex{ + Expression: expression, + }, + } + } + + externalNameByNegatedExpression := func(expression string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + ExpressionRegex: capsuleapi.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } + } + + externalNameByExact := func(exact ...string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + Exact: exact, + } + } + + externalNameByMatch := func(exact []string, expression string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + ExpressionRegex: capsuleapi.ExpressionRegex{ + Expression: expression, + }, + Exact: exact, + } + } + + serviceTypeRule := func(action rules.ActionType, serviceTypes ...rules.ServiceType) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: serviceTypes, + }, + }, + }, + } + } + + loadBalancerCIDRRule := func(action rules.ActionType, cidrs ...string) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: cidrs, + }, + }, + }, + }, + } + } + + externalNameRule := func(action rules.ActionType, hostnames ...capsuleapi.ExpressionMatch) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: hostnames, + }, + }, + }, + }, + } + } + + nodePortRule := func(action rules.ActionType, ports ...rules.ServiceNodePortRange) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: ports, + }, + }, + }, + }, + } + } + + selectedRule := func( + selector map[string]string, + rule *rules.NamespaceRuleBodyTenant, + ) *rules.NamespaceRuleBodyTenant { + rule.NamespaceSelector = &metav1.LabelSelector{ + MatchLabels: selector, + } + + return rule + } + + baseTenantRules := func() []*rules.NamespaceRuleBodyTenant { + return []*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName, + ), + loadBalancerCIDRRule( + rules.ActionTypeAllow, + "10.0.0.2/32", + "10.0.1.0/24", + ), + externalNameRule( + rules.ActionTypeAllow, + externalNameByExact("internal.git.com"), + externalNameByExpression(".*\\.example\\.com"), + externalNameByMatch( + []string{"combined.internal.git.com"}, + "combined\\..*\\.example\\.com", + ), + ), + nodePortRule( + rules.ActionTypeAllow, + rules.ServiceNodePortRange{ + From: 30000, + To: 30100, + }, + rules.ServiceNodePortRange{ + From: 30500, + To: 30500, + }, + ), + nodePortRule( + rules.ActionTypeDeny, + rules.ServiceNodePortRange{ + From: 30090, + To: 30090, + }, + ), + loadBalancerCIDRRule( + rules.ActionTypeDeny, + "10.0.66.0/24", + ), + externalNameRule( + rules.ActionTypeAudit, + externalNameByExpression("audit\\..*"), + ), + selectedRule( + map[string]string{ + "environment": "prod", + }, + loadBalancerCIDRRule( + rules.ActionTypeAllow, + "10.0.171.0/24", + ), + ), + selectedRule( + map[string]string{ + "external-policy": "restricted", + }, + externalNameRule( + rules.ActionTypeDeny, + externalNameByExact("blocked.example.com"), + ), + ), + selectedRule( + map[string]string{ + "negate": "true", + }, + externalNameRule( + rules.ActionTypeDeny, + externalNameByNegatedExpression("trusted\\..*"), + ), + ), + selectedRule( + map[string]string{ + "negate": "true", + }, + externalNameRule( + rules.ActionTypeAllow, + externalNameByExpression("trusted\\..*"), + ), + ), + } + } + + newTenant := func() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-rule-services", + Labels: map[string]string{ + "env": "e2e", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: ownerName, + Kind: "User", + }, + }, + }, + }, + ServiceOptions: &capsuleapi.ServiceOptions{ + AllowedServices: &capsuleapi.AllowedServices{ + ExternalName: ptr.To(true), + LoadBalancer: ptr.To(true), + NodePort: ptr.To(true), + }, + }, + Rules: tenantRules, + }, + } + } + + type expectedServiceStatusRule struct { + action rules.ActionType + types []rules.ServiceType + loadBalancerCIDRs []string + nodePortRanges []rules.ServiceNodePortRange + externalExpressions []string + externalExact [][]string + externalNegated []bool + } + + expectNamespaceStatusRules := func(nsName string, want []expectedServiceStatusRule) { + Eventually(func(g Gomega) { + nsStatus := &capsulev1beta2.RuleStatus{} + + g.Expect(k8sClient.Get( + context.Background(), + client.ObjectKey{ + Name: meta.NameForManagedRuleStatus(), + Namespace: nsName, + }, + nsStatus, + )).To(Succeed()) + + g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want))) + + for i, expected := range want { + got := nsStatus.Status.Rules[i] + + g.Expect(got).NotTo(BeNil()) + g.Expect(got.Enforce).NotTo(BeNil()) + g.Expect(got.Enforce.Action).To(Equal(expected.action)) + + if len(expected.types) == 0 { + g.Expect(got.Enforce.Services.Types).To(BeEmpty()) + } else { + g.Expect(got.Enforce.Services.Types).To(Equal(expected.types)) + } + + if len(expected.loadBalancerCIDRs) == 0 { + if got.Enforce.Services.LoadBalancers != nil { + g.Expect(got.Enforce.Services.LoadBalancers.CIDRs).To(BeEmpty()) + } + } else { + g.Expect(got.Enforce.Services.LoadBalancers).NotTo(BeNil()) + g.Expect(got.Enforce.Services.LoadBalancers.CIDRs).To(Equal(expected.loadBalancerCIDRs)) + } + + if len(expected.nodePortRanges) == 0 { + if got.Enforce.Services.NodePorts != nil { + g.Expect(got.Enforce.Services.NodePorts.Ports).To(BeEmpty()) + } + } else { + g.Expect(got.Enforce.Services.NodePorts).NotTo(BeNil()) + g.Expect(got.Enforce.Services.NodePorts.Ports).To(Equal(expected.nodePortRanges)) + } + + wantHostnames := len(expected.externalExpressions) + if len(expected.externalExact) > wantHostnames { + wantHostnames = len(expected.externalExact) + } + + if wantHostnames == 0 { + if got.Enforce.Services.ExternalNames != nil { + g.Expect(got.Enforce.Services.ExternalNames.Hostnames).To(BeEmpty()) + } + + continue + } + + g.Expect(got.Enforce.Services.ExternalNames).NotTo(BeNil()) + g.Expect(got.Enforce.Services.ExternalNames.Hostnames).To(HaveLen(wantHostnames)) + + for j := 0; j < wantHostnames; j++ { + match := got.Enforce.Services.ExternalNames.Hostnames[j] + + if len(expected.externalExpressions) > j { + g.Expect(match.Expression).To(Equal(expected.externalExpressions[j])) + } else { + g.Expect(match.Expression).To(BeEmpty()) + } + + if len(expected.externalExact) > j { + g.Expect(match.Exact).To(Equal(expected.externalExact[j])) + } else { + g.Expect(match.Exact).To(BeEmpty()) + } + + if len(expected.externalNegated) > j { + g.Expect(match.Negate).To(Equal(expected.externalNegated[j])) + } else { + g.Expect(match.Negate).To(BeFalse()) + } + } + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + baseStatusRules := func() []expectedServiceStatusRule { + return []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName, + }, + }, + { + action: rules.ActionTypeAllow, + loadBalancerCIDRs: []string{ + "10.0.0.2/32", + "10.0.1.0/24", + }, + }, + { + action: rules.ActionTypeAllow, + externalExpressions: []string{ + "", + ".*\\.example\\.com", + "combined\\..*\\.example\\.com", + }, + externalExact: [][]string{ + { + "internal.git.com", + }, + nil, + { + "combined.internal.git.com", + }, + }, + }, + { + action: rules.ActionTypeAllow, + nodePortRanges: []rules.ServiceNodePortRange{ + { + From: 30000, + To: 30100, + }, + { + From: 30500, + To: 30500, + }, + }, + }, + { + action: rules.ActionTypeDeny, + nodePortRanges: []rules.ServiceNodePortRange{ + { + From: 30090, + To: 30090, + }, + }, + }, + { + action: rules.ActionTypeDeny, + loadBalancerCIDRs: []string{ + "10.0.66.0/24", + }, + }, + { + action: rules.ActionTypeAudit, + externalExpressions: []string{ + "audit\\..*", + }, + }, + } + } + + updateTenantRules := func(next []*rules.NamespaceRuleBodyTenant) { + UpdateTenantEventually(tnt, func(current *capsulev1beta2.Tenant) { + current.Spec.Rules = next + }) + + tnt.Spec.Rules = next + } + + createNamespace := func(labels map[string]string) *corev1.Namespace { + if labels == nil { + labels = map[string]string{} + } + + labels[meta.TenantLabel] = tnt.GetName() + + ns := NewNamespace("", labels) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + return ns + } + + createServiceAndExpectDenied := func(cs kubernetes.Interface, nsName string, svc *corev1.Service, substrings ...string) { + base := svc.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "svc" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Services(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected service create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + createServiceAndExpectAllowed := func(cs kubernetes.Interface, nsName string, svc *corev1.Service) { + EventuallyCreation(func() error { + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), svc, metav1.CreateOptions{}) + + return err + }).Should(Succeed()) + } + + updateServiceAndExpectDenied := func( + cs kubernetes.Interface, + nsName string, + svcName string, + mutate func(*corev1.Service), + substrings ...string, + ) { + Eventually(func() error { + svc, err := cs.CoreV1().Services(nsName).Get(context.Background(), svcName, metav1.GetOptions{}) + if err != nil { + return err + } + + mutate(svc) + + _, err = cs.CoreV1().Services(nsName).Update(context.Background(), svc, metav1.UpdateOptions{}) + if err == nil { + return fmt.Errorf("expected service update to be denied, but it succeeded") + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + expectAuditEvent := func( + cs kubernetes.Interface, + namespace string, + serviceName string, + substrings ...string, + ) { + Eventually(func() error { + evt, err := cs.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, e := range evt.Items { + if e.Reason != events.ReasonNamespaceRuleAudit { + continue + } + + if e.InvolvedObject.Kind != "Service" { + continue + } + + eventServiceName := e.InvolvedObject.Name + if eventServiceName != serviceName && !strings.HasPrefix(eventServiceName, serviceName+"-") { + continue + } + + message := e.Message + + matched := true + for _, substring := range substrings { + if !strings.Contains(message, substring) { + matched = false + + break + } + } + + if matched { + return nil + } + } + + return fmt.Errorf( + "expected audit event for service %q containing %q", + serviceName, + substrings, + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + servicePort := func() corev1.ServicePort { + return corev1.ServicePort{ + Name: "http", + Protocol: corev1.ProtocolTCP, + Port: 8080, + TargetPort: intstr.FromInt(8080), + } + } + + clusterIPService := func(name string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + nodePortService := func(name string, nodePort int32) *corev1.Service { + port := servicePort() + port.NodePort = nodePort + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + port, + }, + }, + } + } + + nodePortServiceWithoutExplicitNodePort := func(name string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + createServiceAndExpectDeniedOneOf := func( + cs kubernetes.Interface, + nsName string, + svc *corev1.Service, + alternatives ...[]string, + ) { + base := svc.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "svc" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Services(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected service create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + msg := err.Error() + + for _, alternative := range alternatives { + matches := true + + for _, substring := range alternative { + if !strings.Contains(msg, substring) { + matches = false + + break + } + } + + if matches { + return nil + } + } + + return fmt.Errorf( + "expected error to match one of %v, got: %s", + alternatives, + msg, + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + loadBalancerService := func( + name string, + loadBalancerIP string, + sourceRanges []string, + allocateLoadBalancerNodePorts *bool, + ) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + LoadBalancerIP: loadBalancerIP, + LoadBalancerSourceRanges: sourceRanges, + AllocateLoadBalancerNodePorts: allocateLoadBalancerNodePorts, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + externalNameService := func(name string, externalName string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: externalName, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + BeforeEach(func() { + tenantRules = baseTenantRules() + }) + + JustBeforeEach(func() { + tnt = newTenant() + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + }) + + JustAfterEach(func() { + EventuallyDeletion(tnt) + }) + + It("requires or validates LoadBalancer nodePorts when allocation is enabled and nodePort ranges are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDeniedOneOf( + cs, + ns.Name, + loadBalancerService("lb-auto-node-port-denied", "10.0.0.2", nil, nil), + []string{ + "requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + []string{ + "nodePort", + "not allowed by namespace rule", + "Allowed ranges", + "30000-30100", + "30500", + }, + ) + }) + + It("stores matching tenant service rules as independent status rule blocks", func() { + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.GetName(), baseStatusRules()) + }) + + It("stores namespace-selector matched service rules as additional independent status rule blocks", func() { + ns := createNamespace(map[string]string{ + "environment": "prod", + }) + + want := baseStatusRules() + want = append(want, expectedServiceStatusRule{ + action: rules.ActionTypeAllow, + loadBalancerCIDRs: []string{ + "10.0.171.0/24", + }, + }) + + expectNamespaceStatusRules(ns.GetName(), want) + }) + + It("stores namespace-selector matched negated service rules as independent status rule blocks", func() { + ns := createNamespace(map[string]string{ + "negate": "true", + }) + + want := baseStatusRules() + want = append(want, + expectedServiceStatusRule{ + action: rules.ActionTypeDeny, + externalExpressions: []string{ + "trusted\\..*", + }, + externalNegated: []bool{ + true, + }, + }, + expectedServiceStatusRule{ + action: rules.ActionTypeAllow, + externalExpressions: []string{ + "trusted\\..*", + }, + }, + ) + + expectNamespaceStatusRules(ns.GetName(), want) + }) + + It("allows a listed ClusterIP service type", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, clusterIPService("cluster-ip-allowed")) + }) + + It("denies a service type missing from services.types and reports allowed service types", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-name-type-denied", "internal.git.com"), + "service type", + "ExternalName", + "not allowed", + "Allowed service types", + "ClusterIP", + ) + }) + + It("allows exact, regex, and combined ExternalName hostname matchers", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-exact-allowed", "internal.git.com")) + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-regex-allowed", "api.example.com")) + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-combined-exact-allowed", "combined.internal.git.com")) + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-combined-regex-allowed", "combined.api.example.com")) + }) + + It("denies non-matching ExternalName hostnames and reports allowed hostname rules", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-denied", "api.bad.com"), + "externalName hostname", + "api.bad.com", + "spec.externalName", + "not allowed", + "Allowed hostnames", + "exact: internal.git.com", + "exp: .*\\.example\\.com", + ) + }) + + It("audits a matching ExternalName but still denies when no allow rule matches", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeExternalName, + ), + externalNameRule( + rules.ActionTypeAudit, + externalNameByExpression("audit\\..*"), + ), + externalNameRule( + rules.ActionTypeAllow, + externalNameByExpression("allowed\\..*"), + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeExternalName, + }, + }, + { + action: rules.ActionTypeAudit, + externalExpressions: []string{ + "audit\\..*", + }, + }, + { + action: rules.ActionTypeAllow, + externalExpressions: []string{ + "allowed\\..*", + }, + }, + }) + + svc := externalNameService("external-audit-denied", "audit.internal") + + createServiceAndExpectDenied(clusterAdminClient(), ns.Name, svc, + "externalName hostname", + "audit.internal", + "not allowed", + "Allowed hostnames", + "allowed\\..*", + ) + + expectAuditEvent(clusterAdminClient(), ns.Name, svc.Name, + "matched audit", + "audit\\..*", + ) + }) + + It("denies a later selected ExternalName deny rule after an earlier allow rule matched", func() { + ns := createNamespace(map[string]string{ + "external-policy": "restricted", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-selected-denied", "blocked.example.com"), + "externalName hostname", + "blocked.example.com", + "denied", + "exact: blocked.example.com", + ) + }) + + It("applies namespace-selector matched negated ExternalName rules after base rules", func() { + ns := createNamespace(map[string]string{ + "negate": "true", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-negated-denied", "api.example.com"), + "externalName hostname", + "api.example.com", + "denied", + "exp: trusted\\..*", + ) + + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-negated-allowed", "trusted.api")) + }) + + It("allows LoadBalancer IPs and source ranges contained in configured CIDRs", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-ip-allowed", "10.0.0.2", nil, ptr.To(false))) + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-ip-range-allowed", "10.0.1.44", nil, ptr.To(false))) + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-source-range-allowed", "", []string{"10.0.1.0/25"}, ptr.To(false))) + }) + + It("denies LoadBalancer IPs outside configured CIDRs and reports allowed CIDRs", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-ip-denied", "10.0.171.239", nil, ptr.To(false)), + "loadBalancer CIDR", + "10.0.171.239", + "spec.loadBalancerIP", + "not allowed", + "Allowed CIDRs", + "10.0.0.2/32", + "10.0.1.0/24", + ) + }) + + It("denies LoadBalancer source ranges not fully contained in configured CIDRs", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-source-range-denied", "", []string{"10.0.1.0/23"}, ptr.To(false)), + "loadBalancer CIDR", + "10.0.1.0/23", + "spec.loadBalancerSourceRanges[0]", + "Allowed CIDRs", + ) + }) + + It("requires LoadBalancer IP or source ranges when CIDR constraints are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-required-value-denied", "", nil, ptr.To(false)), + "requires spec.loadBalancerIP or spec.loadBalancerSourceRanges", + "loadBalancer CIDR constraints are enforced by namespace rule", + ) + }) + + It("denies a later LoadBalancer CIDR deny rule after an earlier allow rule matched", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-later-deny", "10.0.66.10", nil, ptr.To(false)), + "loadBalancer CIDR", + "10.0.66.10", + "denied", + "10.0.66.0/24", + ) + }) + + It("allows a later selected LoadBalancer allow rule to override an earlier allow miss", func() { + ns := createNamespace(map[string]string{ + "environment": "prod", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-prod-selected-allowed", "10.0.171.239", nil, ptr.To(false))) + }) + + It("allows explicit nodePorts inside configured ranges", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, nodePortService("node-port-range-allowed", 30080)) + createServiceAndExpectAllowed(cs, ns.Name, nodePortService("node-port-single-allowed", 30500)) + }) + + It("denies explicit nodePorts outside configured ranges and reports allowed ranges", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, nodePortService("node-port-range-denied", 32080), + "nodePort", + "32080", + "not allowed", + "Allowed ranges", + "30000-30100", + "30500", + ) + }) + + It("requires explicit nodePorts when nodePort ranges are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDeniedOneOf( + cs, + ns.Name, + nodePortServiceWithoutExplicitNodePort("node-port-required-denied"), + []string{ + "requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + []string{ + "nodePort", + "not allowed by namespace rule", + "Allowed ranges", + "30000-30100", + "30500", + }, + ) + }) + It("denies a later nodePort deny rule after an earlier allow rule matched", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied( + cs, + ns.Name, + nodePortService("node-port-later-deny", 30090), + "nodePort", + "30090", + "denied", + "30090", + ) + }) + + It("requires or validates LoadBalancer nodePorts when allocation is enabled and nodePort ranges are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDeniedOneOf( + cs, + ns.Name, + loadBalancerService("lb-auto-node-port-denied", "10.0.0.2", nil, nil), + []string{ + "requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + []string{ + "nodePort", + "not allowed by namespace rule", + "Allowed ranges", + "30000-30100", + "30500", + }, + ) + }) + + It("does not require LoadBalancer nodePorts when allocation is explicitly disabled", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed( + cs, + ns.Name, + loadBalancerService("lb-node-port-allocation-disabled", "10.0.0.2", nil, ptr.To(false)), + ) + }) + + It("denies an update when the new ExternalName no longer matches the allowed hostname rules", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeExternalName, + ), + externalNameRule( + rules.ActionTypeAllow, + externalNameByExact("internal.git.com"), + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeExternalName, + }, + }, + { + action: rules.ActionTypeAllow, + externalExact: [][]string{ + { + "internal.git.com", + }, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + svc := externalNameService("external-update-denied", "internal.git.com") + createServiceAndExpectAllowed(cs, ns.Name, svc) + + updateServiceAndExpectDenied( + cs, + ns.Name, + svc.Name, + func(svc *corev1.Service) { + svc.Spec.ExternalName = "api.bad.com" + }, + "externalName hostname", + "api.bad.com", + "not allowed", + "Allowed hostnames", + ) + }) + + It("allows service creation when no matching service-specific constraint exists for the allowed type", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + rules.ServiceTypeLoadBalancer, + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceTypeLoadBalancer, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed( + cs, + ns.Name, + loadBalancerService("lb-no-cidr-rule-allowed", "", nil, ptr.To(false)), + ) + }) +}) diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index a5205ce8c..3a7e0b2d9 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -91,10 +91,20 @@ spec: rules: - enforce: action: "allow" - workloads: - schedulers: - - exact: - - "{{ .tenant.metadata.name }}-scheduler" + services: + types: ["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"] + loadBalancers: + cidrs: + - 10.0.0.2/32 + externalNames: + hostnames: + - exp: ".*\\.example\\.com" + exact: + - "internal.git.com" + nodePorts: + ports: + - from: 30000 + to: 32767 --- apiVersion: capsule.clastix.io/v1beta2 kind: Tenant diff --git a/internal/controllers/admission/mutating.go b/internal/controllers/admission/mutating.go index d1f6302fb..9d8ff24a7 100644 --- a/internal/controllers/admission/mutating.go +++ b/internal/controllers/admission/mutating.go @@ -17,7 +17,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -69,7 +68,7 @@ func (r *mutatingReconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/admission/validating.go b/internal/controllers/admission/validating.go index 22070e01a..ef47bf5be 100644 --- a/internal/controllers/admission/validating.go +++ b/internal/controllers/admission/validating.go @@ -17,7 +17,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -69,7 +68,7 @@ func (r *validatingReconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig uti predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/customquotas/custom_quota_controller.go b/internal/controllers/customquotas/custom_quota_controller.go index 328e07249..201e552e8 100644 --- a/internal/controllers/customquotas/custom_quota_controller.go +++ b/internal/controllers/customquotas/custom_quota_controller.go @@ -20,7 +20,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -49,7 +48,7 @@ type customQuotaClaimController struct { targetsCache *cache.CompiledTargetsCache[string] } -func (r *customQuotaClaimController) SetupWithManager(mgr ctrl.Manager, cfg cutils.ControllerOptions) error { +func (r *customQuotaClaimController) SetupWithManager(mgr ctrl.Manager, ctrlConfig cutils.ControllerOptions) error { r.mapper = mgr.GetRESTMapper() r.reader = mgr.GetAPIReader() @@ -71,7 +70,7 @@ func (r *customQuotaClaimController) SetupWithManager(mgr ctrl.Manager, cfg cuti &capsulev1beta2.CustomQuota{}, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/customquotas/global_custom_quota_controller.go b/internal/controllers/customquotas/global_custom_quota_controller.go index 8bba8928d..55a31aa3c 100644 --- a/internal/controllers/customquotas/global_custom_quota_controller.go +++ b/internal/controllers/customquotas/global_custom_quota_controller.go @@ -23,7 +23,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -55,7 +54,7 @@ type clusterCustomQuotaClaimController struct { targetsCache *cache.CompiledTargetsCache[string] } -func (r *clusterCustomQuotaClaimController) SetupWithManager(mgr ctrl.Manager, cfg cutils.ControllerOptions) error { +func (r *clusterCustomQuotaClaimController) SetupWithManager(mgr ctrl.Manager, ctrlConfig cutils.ControllerOptions) error { r.mapper = mgr.GetRESTMapper() r.reader = mgr.GetAPIReader() @@ -104,7 +103,7 @@ func (r *clusterCustomQuotaClaimController) SetupWithManager(mgr ctrl.Manager, c }, }), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/pv/controller.go b/internal/controllers/pv/controller.go index 25e1fb450..c3e33084a 100644 --- a/internal/controllers/pv/controller.go +++ b/internal/controllers/pv/controller.go @@ -12,7 +12,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" log2 "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -28,7 +27,7 @@ type Controller struct { label string } -func (c *Controller) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error { +func (c *Controller) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { label, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{}) if err != nil { return err @@ -54,7 +53,7 @@ func (c *Controller) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOpti return !ok }))). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(c) } diff --git a/internal/controllers/resourcepools/claim_controller.go b/internal/controllers/resourcepools/claim_controller.go index 7f0cb451a..c536e17af 100644 --- a/internal/controllers/resourcepools/claim_controller.go +++ b/internal/controllers/resourcepools/claim_controller.go @@ -18,7 +18,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -40,7 +39,7 @@ type resourceClaimController struct { recorder events.EventRecorder } -func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error { +func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.reader = mgr.GetAPIReader() return ctrl.NewControllerManagedBy(mgr). @@ -56,7 +55,7 @@ func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, cfg utils.C handler.EnqueueRequestsFromMapFunc(r.claimsWithoutPoolFromNamespaces), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } @@ -64,7 +63,7 @@ func (r resourceClaimController) Reconcile(ctx context.Context, request ctrl.Req log := r.log.WithValues("Request.Name", request.Name) instance := &capsulev1beta2.ResourcePoolClaim{} - if err = r.reader.Get(ctx, request.NamespacedName, instance); err != nil { + if err = r.Get(ctx, request.NamespacedName, instance); err != nil { if apierrors.IsNotFound(err) { log.V(5).Info("Request object not found, could have been deleted after reconcile request") diff --git a/internal/controllers/resourcepools/pool_controller.go b/internal/controllers/resourcepools/pool_controller.go index 562094764..5ddc76869 100644 --- a/internal/controllers/resourcepools/pool_controller.go +++ b/internal/controllers/resourcepools/pool_controller.go @@ -22,7 +22,6 @@ import ( "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -47,7 +46,7 @@ type resourcePoolController struct { recorder events.EventRecorder } -func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutils.ControllerOptions) error { +func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, ctrlConfig ctrlutils.ControllerOptions) error { r.reader = mgr.GetAPIReader() return ctrl.NewControllerManagedBy(mgr). @@ -61,7 +60,7 @@ func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutil handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { // Fetch all GlobalResourceQuota objects grqList := &capsulev1beta2.ResourcePoolList{} - if err := r.reader.List(ctx, grqList); err != nil { + if err := r.Client.List(ctx, grqList); err != nil { r.log.Error(err, "Failed to list ResourcePools objects") return nil @@ -78,7 +77,7 @@ func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutil return requests }), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/resources/global.go b/internal/controllers/resources/global.go index d5e631256..dd407c69c 100644 --- a/internal/controllers/resources/global.go +++ b/internal/controllers/resources/global.go @@ -20,7 +20,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" @@ -53,7 +52,7 @@ type globalResourceController struct { impersonation *cache.ImpersonationCache } -func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error { +func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.client = mgr.GetClient() r.reader = mgr.GetAPIReader() @@ -83,7 +82,7 @@ func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, cfg utils. handler.EnqueueRequestsFromMapFunc(r.enqueueAllResources), builder.WithPredicates( predicates.CapsuleConfigSpecImpersonationChangedPredicate{}, - predicates.NamesMatchingPredicate{Names: []string{cfg.ConfigurationName}}, + predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). Watches( @@ -94,7 +93,7 @@ func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, cfg utils. &capsulev1beta2.Tenant{}, handler.EnqueueRequestsFromMapFunc(r.enqueueRequestFromTenant), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/resources/namespaced.go b/internal/controllers/resources/namespaced.go index b9ea12609..5db871b85 100644 --- a/internal/controllers/resources/namespaced.go +++ b/internal/controllers/resources/namespaced.go @@ -21,7 +21,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -56,7 +55,7 @@ type namespacedResourceController struct { impersonation *cache.ImpersonationCache } -func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, cfg cutils.ControllerOptions) error { +func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, ctrlConfig cutils.ControllerOptions) error { r.client = mgr.GetClient() r.reader = mgr.GetAPIReader() @@ -90,7 +89,7 @@ func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, cfg cu handler.EnqueueRequestsFromMapFunc(r.enqueueAllResources), builder.WithPredicates( predicates.CapsuleConfigSpecImpersonationChangedPredicate{}, - predicates.NamesMatchingPredicate{Names: []string{cfg.ConfigurationName}}, + predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). Watches( @@ -131,7 +130,7 @@ func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, cfg cu }, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index bbe9166b0..99a8a35cb 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -19,14 +19,12 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/internal/metrics" - "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" meta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rules" @@ -60,7 +58,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller ), ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}) + WithOptions(ctrlConfig.Runtime.ToControllerOptions()) return ctrlBuilder.Complete(r) } @@ -145,39 +143,13 @@ func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleSta ruleStatus := make([]*rules.NamespaceRuleBodyNamespace, 0, len(instance.Spec)) for _, rule := range instance.Spec { - if rule == nil { + if rule == nil || rule.Enforce == nil { continue } - if rule.Enforce == nil { - continue - } - - normalized := &rules.NamespaceRuleBodyNamespace{ - Enforce: &rules.NamespaceRuleEnforceBody{ - Action: rule.Enforce.Action, - Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Targets: append( - []rules.WorkloadValidationTarget(nil), - rule.Enforce.Workloads.Targets..., - ), - Registries: append( - []rules.OCIRegistry(nil), - rule.Enforce.Workloads.Registries..., - ), - QoSClasses: append( - []corev1.PodQOSClass(nil), - rule.Enforce.Workloads.QoSClasses..., - ), - Schedulers: append( - []api.ExpressionMatch(nil), - rule.Enforce.Workloads.Schedulers..., - ), - }, - }, - } - - ruleStatus = append(ruleStatus, normalized) + ruleStatus = append(ruleStatus, &rules.NamespaceRuleBodyNamespace{ + Enforce: rule.Enforce.DeepCopy(), + }) } instance.Status.Rules = ruleStatus diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index 08bb25cab..b637412c2 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -30,7 +30,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -202,7 +201,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller }, builder.WithPredicates(predicates.PromotedServiceaccountPredicate{}), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}) + WithOptions(ctrlConfig.Runtime.ToControllerOptions()) // GatewayClass is Optional r.classes.gateway = gvk.HasGVK(mgr.GetRESTMapper(), schema.GroupVersionKind{ diff --git a/internal/controllers/tenantowner/manager.go b/internal/controllers/tenantowner/manager.go index 69736de4a..72898fabc 100644 --- a/internal/controllers/tenantowner/manager.go +++ b/internal/controllers/tenantowner/manager.go @@ -111,6 +111,7 @@ func (r *TenantOwnerManager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils }, }, ). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/utils/options.go b/internal/controllers/utils/options.go index a517ccd0a..9d10aceb8 100644 --- a/internal/controllers/utils/options.go +++ b/internal/controllers/utils/options.go @@ -3,7 +3,30 @@ package utils +import ( + "time" + + "sigs.k8s.io/controller-runtime/pkg/controller" +) + type ControllerOptions struct { - ConfigurationName string + ConfigurationName string + Runtime RuntimeControllerOptions +} + +type RuntimeControllerOptions struct { MaxConcurrentReconciles int + CacheSyncTimeout time.Duration +} + +func (o RuntimeControllerOptions) ToControllerOptions() controller.Options { + out := controller.Options{ + MaxConcurrentReconciles: o.MaxConcurrentReconciles, + } + + if o.CacheSyncTimeout > 0 { + out.CacheSyncTimeout = o.CacheSyncTimeout + } + + return out } diff --git a/internal/webhook/route/rules.go b/internal/webhook/route/rules.go new file mode 100644 index 000000000..d8c65acbe --- /dev/null +++ b/internal/webhook/route/rules.go @@ -0,0 +1,30 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package route + +import ( + "github.com/projectcapsule/capsule/internal/webhook/rules/status" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type rulesValidating struct { + configuration configuration.Configuration +} + +func RulesValidating(configuration configuration.Configuration) handlers.Webhook { + return &rulesValidating{ + configuration: configuration, + } +} + +func (w *rulesValidating) GetHandlers() []handlers.Handler { + return []handlers.Handler{ + status.RuleStatusValidationHandler(w.configuration), + } +} + +func (rulesValidating) GetPath() string { + return "/rulestatus/validating" +} diff --git a/internal/webhook/rules/pods/validation/factory_test.go b/internal/webhook/rules/pods/validation/factory_test.go new file mode 100644 index 000000000..7b99d6f2d --- /dev/null +++ b/internal/webhook/rules/pods/validation/factory_test.go @@ -0,0 +1,12 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import "github.com/projectcapsule/capsule/internal/cache" + +func podRulesForTest() *podRules { + return &podRules{ + regexCache: cache.NewRegexCache(), + } +} diff --git a/internal/webhook/rules/pods/validation/qos.go b/internal/webhook/rules/pods/validation/qos.go index 962747d70..ef603a1d2 100644 --- a/internal/webhook/rules/pods/validation/qos.go +++ b/internal/webhook/rules/pods/validation/qos.go @@ -42,6 +42,10 @@ func (h *podRules) validateQoSClasses( Matched: string(match) == value.Value, }, nil }, + RuleDescription: func(rule corev1.PodQOSClass) string { + return string(rule) + }, + AllowedDescription: "Allowed QoS classes", }, ) } diff --git a/internal/webhook/rules/pods/validation/qos_test.go b/internal/webhook/rules/pods/validation/qos_test.go new file mode 100644 index 000000000..7ff015497 --- /dev/null +++ b/internal/webhook/rules/pods/validation/qos_test.go @@ -0,0 +1,466 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestPodRulesValidateQoSClasses(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "BestEffort pod without QoS rules returns empty evaluation", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "nil enforce body is ignored", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + `matched allowed rule BestEffort`, + }, + }, + { + name: "allow BestEffort QoS class", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + `matched allowed rule BestEffort`, + }, + }, + { + name: "allow Burstable QoS class", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBurstable, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass is allowed by namespace rule`, + `matched allowed rule Burstable`, + }, + }, + { + name: "allow Guaranteed QoS class", + pod: guaranteedPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Guaranteed" at status.qosClass is allowed by namespace rule`, + `matched allowed rule Guaranteed`, + }, + }, + { + name: "allow miss denies QoS class missing from allowed list", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBurstable, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is not allowed by namespace rule`, + `Allowed QoS classes`, + `Burstable`, + `Guaranteed`, + }, + }, + { + name: "deny matching QoS class", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is denied by namespace rule`, + `matched denied rule BestEffort`, + }, + }, + { + name: "default action is deny", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + "", + corev1.PodQOSBurstable, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass is denied by namespace rule`, + `matched denied rule Burstable`, + }, + }, + { + name: "later deny overrides earlier allow", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is denied by namespace rule`, + `matched denied rule BestEffort`, + }, + }, + { + name: "later allow overrides earlier deny", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + `matched allowed rule BestEffort`, + }, + }, + { + name: "non matching later deny does not override earlier allow", + pod: guaranteedPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Guaranteed" at status.qosClass is allowed by namespace rule`, + `matched allowed rule Guaranteed`, + }, + }, + { + name: "audit match is observational", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAudit, + corev1.PodQOSBurstable, + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass matched audit namespace rule`, + `matched audit rule Burstable`, + }, + }, + { + name: "audit does not satisfy allow list", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAudit, + corev1.PodQOSBurstable, + ), + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass is not allowed by namespace rule`, + `Allowed QoS classes`, + `Guaranteed`, + }, + }, + { + name: "unsupported action returns error", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionType("invalid"), + corev1.PodQOSBestEffort, + ), + }, + wantErr: `QoS class: unsupported rule action "invalid"`, + }, + { + name: "uses existing status qosClass when present", + pod: func() *corev1.Pod { + pod := bestEffortPodForQoSTest() + pod.Status.QOSClass = corev1.PodQOSGuaranteed + + return pod + }(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Guaranteed" at status.qosClass is allowed by namespace rule`, + }, + }, + { + name: "empty pod still evaluates as BestEffort", + pod: &corev1.Pod{}, + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := podRulesForTest() + + evaluation, err := h.validateQoSClasses(tt.pod, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForQoSTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenPodQoSClass { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenPodQoSClass) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenPodQoSClass { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenPodQoSClass) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenPodQoSClass { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenPodQoSClass) + } + } + }) + } +} + +func qosEnforceForTest( + action apirules.ActionType, + classes ...corev1.PodQOSClass, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Workloads: apirules.NamespaceRuleEnforceWorkloadsBody{ + QoSClasses: classes, + }, + } +} + +func bestEffortPodForQoSTest() *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + }, + }, + }, + } +} + +func burstablePodForQoSTest() *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + }, + }, + } +} + +func guaranteedPodForQoSTest() *corev1.Pod { + cpu := resource.MustParse("100m") + memory := resource.MustParse("128Mi") + + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: cpu, + corev1.ResourceMemory: memory, + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: cpu, + corev1.ResourceMemory: memory, + }, + }, + }, + }, + }, + } +} + +func decisionMessageForQoSTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/pods/validation/registry.go b/internal/webhook/rules/pods/validation/registry.go index 55134de91..54cd5e01c 100644 --- a/internal/webhook/rules/pods/validation/registry.go +++ b/internal/webhook/rules/pods/validation/registry.go @@ -11,6 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/internal/cache" + rulesutils "github.com/projectcapsule/capsule/internal/webhook/rules" apirules "github.com/projectcapsule/capsule/pkg/api/rules" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" @@ -153,11 +154,34 @@ func (h *podRules) evaluateRegistryReference( MatchedValue: matched, }, nil }, - Message: registryDecisionMessage, + Message: registryDecisionMessage, + RuleDescription: describeRegistryRuleSet, + AllowedDescription: "Allowed registries", }, ) } +func describeRegistryRuleSet(rule registryRuleSet) string { + if len(rule.Registries) == 0 { + return "" + } + + parts := make([]string, 0, len(rule.Registries)) + + for _, registry := range rule.Registries { + description := strings.TrimSpace( + rulesutils.DescribeExpressionMatch(registry.ExpressionMatch), + ) + if description == "" { + continue + } + + parts = append(parts, description) + } + + return strings.Join(parts, ", ") +} + func registryReferencesFromPod(pod *corev1.Pod) []registryReference { if pod == nil { return nil diff --git a/internal/webhook/rules/pods/validation/registry_test.go b/internal/webhook/rules/pods/validation/registry_test.go new file mode 100644 index 000000000..0c155165d --- /dev/null +++ b/internal/webhook/rules/pods/validation/registry_test.go @@ -0,0 +1,1157 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestPodRulesValidateRegistriesPreconditions(t *testing.T) { + t.Run("nil registry cache returns error", func(t *testing.T) { + h := &podRules{} + + evaluation, err := h.validateRegistries( + registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + ) + + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !strings.Contains(err.Error(), "registry rule set cache is nil") { + t.Fatalf("expected registry cache error, got %q", err.Error()) + } + + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + }) + + t.Run("nil pod returns nil evaluation when cache exists", func(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + evaluation, err := h.validateRegistries( + nil, + []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + ) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + }) + + t.Run("empty enforce bodies returns nil evaluation when cache exists", func(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + evaluation, err := h.validateRegistries( + registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + nil, + ) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + }) +} + +func TestPodRulesValidateRegistries(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "empty container image reference is denied before registry evaluation", + pod: registryPodForTest("", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + wantBlocking: true, + wantMessage: []string{ + "containers[0] has empty reference", + }, + }, + { + name: "blank container image reference is denied before registry evaluation", + pod: registryPodForTest(" ", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + wantBlocking: true, + wantMessage: []string{ + "containers[0] has empty reference", + }, + }, + { + name: "allow matching registry", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule`, + `exp=harbor/platform/.*`, + }, + }, + { + name: "allow exact registry", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExactForTest("harbor/platform/app:1.0.0"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule`, + `exact=harbor/platform/app:1.0.0`, + }, + }, + { + name: "allow miss denies registry and reports allowed registries", + pod: registryPodForTest("docker.io/library/nginx:latest", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + registryExpressionForTest("registry.local/.*"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `registry "docker.io/library/nginx:latest" at containers[0] is not allowed by namespace rule`, + `Allowed registries`, + `exp: harbor/.*`, + `exp: registry.local/.*`, + }, + }, + { + name: "deny matching registry", + pod: registryPodForTest("harbor/customer/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/app:1.0.0" is denied by registry rule`, + `exp=harbor/customer/.*`, + }, + }, + { + name: "default action is deny", + pod: registryPodForTest("harbor/customer/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + "", + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/app:1.0.0" is denied by registry rule`, + `exp=harbor/customer/.*`, + }, + }, + { + name: "later deny overrides earlier allow", + pod: registryPodForTest("harbor/customer/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/app:1.0.0" is denied by registry rule`, + `exp=harbor/customer/.*`, + }, + }, + { + name: "later allow overrides earlier deny", + pod: registryPodForTest("harbor/customer/prod/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/customer/prod/.*"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/prod/app:1.0.0" is allowed by registry rule`, + `exp=harbor/customer/prod/.*`, + }, + }, + { + name: "audit match is observational", + pod: registryPodForTest("audit/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAudit, + nil, + registryExpressionForTest("audit/.*"), + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `containers[0] reference "audit/app:1.0.0" matched audit registry rule`, + `exp=audit/.*`, + }, + }, + { + name: "audit does not satisfy allow list", + pod: registryPodForTest("audit/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAudit, + nil, + registryExpressionForTest("audit/.*"), + ), + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("allowed/.*"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `registry "audit/app:1.0.0" at containers[0] is not allowed by namespace rule`, + `Allowed registries`, + `exp: allowed/.*`, + }, + }, + { + name: "invalid registry regex returns error", + pod: registryPodForTest("harbor/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("["), + ), + }, + wantErr: "registry: invalid rule", + }, + { + name: "pull policy missing is denied when matching allow rule requires policy", + pod: registryPodForTest("harbor/platform/app:1.0.0", ""), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + []corev1.PullPolicy{corev1.PullAlways}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" must explicitly set pullPolicy`, + `allowed: Always`, + }, + }, + { + name: "pull policy mismatch is denied after allow", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullNever), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" uses pullPolicy=Never which is not allowed`, + `allowed: Always, IfNotPresent`, + }, + }, + { + name: "pull policy match is allowed after allow", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullIfNotPresent), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule`, + }, + }, + { + name: "pull policy is not evaluated for final deny", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullNever), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + []corev1.PullPolicy{corev1.PullAlways}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is denied by registry rule`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + evaluation, err := h.validateRegistries(tt.pod, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForRegistryTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenContainerRegistry { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenContainerRegistry) + } + } + + if evaluation.Blocking != nil { + switch evaluation.Blocking.EventReason { + case events.ReasonForbiddenContainerRegistry, events.ReasonForbiddenPullPolicy: + default: + t.Fatalf( + "blocking event reason = %q, want %q or %q", + evaluation.Blocking.EventReason, + events.ReasonForbiddenContainerRegistry, + events.ReasonForbiddenPullPolicy, + ) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenContainerRegistry { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenContainerRegistry) + } + } + }) + } +} + +func TestPodRulesEvaluateRegistryReference(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + ref := registryReference{ + Target: apirules.ValidateContainers, + Reference: "harbor/platform/app:1.0.0", + PullPolicy: corev1.PullAlways, + Path: "containers[0]", + } + + evaluation, err := h.evaluateRegistryReference(ref, []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/platform/.*"), + ), + }) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if evaluation.Final.Action != apirules.ActionTypeAllow { + t.Fatalf("final action = %q, want %q", evaluation.Final.Action, apirules.ActionTypeAllow) + } + + if evaluation.Final.MatchedValue == nil { + t.Fatalf("expected matched value") + } +} + +func TestDescribeRegistryRuleSet(t *testing.T) { + tests := []struct { + name string + rule registryRuleSet + want string + }{ + { + name: "empty rule set", + rule: registryRuleSet{}, + want: "", + }, + { + name: "exact", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + registryExactForTest("harbor/platform/app:1.0.0"), + }, + }, + want: "exact: harbor/platform/app:1.0.0", + }, + { + name: "expression", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + registryExpressionForTest("harbor/.*"), + }, + }, + want: "exp: harbor/.*", + }, + { + name: "exact and expression", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "harbor/platform/app:1.0.0", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "harbor/shared/.*", + }, + }, + }, + }, + }, + want: "exact: harbor/platform/app:1.0.0; exp: harbor/shared/.*", + }, + { + name: "multiple registries", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + registryExpressionForTest("harbor/.*"), + registryExpressionForTest("registry.local/.*"), + }, + }, + want: "exp: harbor/.*, exp: registry.local/.*", + }, + { + name: "skips empty description", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + {}, + registryExpressionForTest("harbor/.*"), + }, + }, + want: "exp: harbor/.*", + }, + { + name: "negated expression", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted/.*", + Negate: true, + }, + }, + }, + }, + }, + want: "not exp: trusted/.*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeRegistryRuleSet(tt.rule) + if got != tt.want { + t.Fatalf("describeRegistryRuleSet() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryReferencesFromPod(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "init", + Image: "harbor/init/app:1.0.0", + ImagePullPolicy: corev1.PullAlways, + }, + }, + Containers: []corev1.Container{ + { + Name: "app", + Image: "harbor/app/app:1.0.0", + ImagePullPolicy: corev1.PullIfNotPresent, + }, + }, + EphemeralContainers: []corev1.EphemeralContainer{ + { + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "debug", + Image: "harbor/debug/app:1.0.0", + ImagePullPolicy: corev1.PullNever, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{}, + }, + }, + { + Name: "artifact", + VolumeSource: corev1.VolumeSource{ + Image: &corev1.ImageVolumeSource{ + Reference: "harbor/volume/artifact:1.0.0", + PullPolicy: corev1.PullAlways, + }, + }, + }, + }, + }, + } + + got := registryReferencesFromPod(pod) + + want := []registryReference{ + { + Target: apirules.ValidateInitContainers, + Reference: "harbor/init/app:1.0.0", + PullPolicy: corev1.PullAlways, + Path: "initContainers[0]", + }, + { + Target: apirules.ValidateContainers, + Reference: "harbor/app/app:1.0.0", + PullPolicy: corev1.PullIfNotPresent, + Path: "containers[0]", + }, + { + Target: apirules.ValidateEphemeralContainers, + Reference: "harbor/debug/app:1.0.0", + PullPolicy: corev1.PullNever, + Path: "ephemeralContainers[0]", + }, + { + Target: apirules.ValidateVolumes, + Reference: "harbor/volume/artifact:1.0.0", + PullPolicy: corev1.PullAlways, + Path: "volumes[1](artifact)", + }, + } + + if len(got) != len(want) { + t.Fatalf("expected %d refs, got %d: %#v", len(want), len(got), got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("ref[%d] = %#v, want %#v", i, got[i], want[i]) + } + } +} + +func TestRegistryReferencesFromPodNil(t *testing.T) { + if got := registryReferencesFromPod(nil); got != nil { + t.Fatalf("registryReferencesFromPod(nil) = %#v, want nil", got) + } +} + +func TestRegistryDecisionMessage(t *testing.T) { + matched := compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + nil, + ) + + tests := []struct { + name string + action apirules.ActionType + value ruleengine.Value + match any + want string + }{ + { + name: "audit", + action: apirules.ActionTypeAudit, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" matched audit registry rule "exp=harbor/platform/.*"`, + }, + { + name: "deny", + action: apirules.ActionTypeDeny, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" is denied by registry rule "exp=harbor/platform/.*"`, + }, + { + name: "allow", + action: apirules.ActionTypeAllow, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule "exp=harbor/platform/.*"`, + }, + { + name: "unknown action", + action: apirules.ActionType("custom"), + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" matched registry rule "exp=harbor/platform/.*" with action "custom"`, + }, + { + name: "nil matched value", + action: apirules.ActionTypeAllow, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: nil, + want: `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule ""`, + }, + { + name: "wrong matched value type", + action: apirules.ActionTypeAllow, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: "not-a-compiled-rule", + want: `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule ""`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryDecisionMessage(tt.action, tt.value, tt.match) + if got != tt.want { + t.Fatalf("registryDecisionMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryRuleDescription(t *testing.T) { + tests := []struct { + name string + matched *cache.CompiledRule + want string + }{ + { + name: "nil", + matched: nil, + want: "", + }, + { + name: "unknown empty rule", + matched: &cache.CompiledRule{ + Match: api.ExpressionMatch{}, + }, + want: "", + }, + { + name: "exact values are sorted", + matched: compiledRegistryRuleForTest( + registryExactForTest("z.registry/app:1.0.0", "a.registry/app:1.0.0"), + nil, + ), + want: "exact=a.registry/app:1.0.0,z.registry/app:1.0.0", + }, + { + name: "expression", + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + nil, + ), + want: "exp=harbor/platform/.*", + }, + { + name: "negated expression", + matched: compiledRegistryRuleForTest( + apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted/.*", + Negate: true, + }, + }, + }, + nil, + ), + want: "exp=trusted/.*,negate=true", + }, + { + name: "exact and expression", + matched: compiledRegistryRuleForTest( + apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "harbor/platform/app:1.0.0", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "harbor/shared/.*", + }, + }, + }, + nil, + ), + want: "exact=harbor/platform/app:1.0.0;exp=harbor/shared/.*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryRuleDescription(tt.matched) + if got != tt.want { + t.Fatalf("registryRuleDescription() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryPullPolicyDecision(t *testing.T) { + ref := registryReference{ + Target: apirules.ValidateContainers, + Reference: "harbor/platform/app:1.0.0", + Path: "containers[0]", + } + + tests := []struct { + name string + ref registryReference + matched *cache.CompiledRule + wantNil bool + wantReason string + wantMessage []string + }{ + { + name: "nil matched rule", + ref: ref, + matched: nil, + wantNil: true, + }, + { + name: "no allowed policy", + ref: ref, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + nil, + ), + wantNil: true, + }, + { + name: "empty allowed policy", + ref: ref, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{}, + ), + wantNil: true, + }, + { + name: "missing pull policy is denied", + ref: ref, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{corev1.PullAlways}, + ), + wantReason: events.ReasonForbiddenPullPolicy, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" must explicitly set pullPolicy`, + `allowed: Always`, + }, + }, + { + name: "disallowed pull policy is denied", + ref: registryReference{ + Target: ref.Target, + Reference: ref.Reference, + Path: ref.Path, + PullPolicy: corev1.PullNever, + }, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{ + corev1.PullAlways, + corev1.PullIfNotPresent, + }, + ), + wantReason: events.ReasonForbiddenPullPolicy, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" uses pullPolicy=Never which is not allowed`, + `allowed: Always, IfNotPresent`, + }, + }, + { + name: "allowed pull policy succeeds", + ref: registryReference{ + Target: ref.Target, + Reference: ref.Reference, + Path: ref.Path, + PullPolicy: corev1.PullIfNotPresent, + }, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{ + corev1.PullAlways, + corev1.PullIfNotPresent, + }, + ), + wantNil: true, + }, + { + name: "allowed policies are sorted in message", + ref: registryReference{ + Target: ref.Target, + Reference: ref.Reference, + Path: ref.Path, + PullPolicy: corev1.PullNever, + }, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{ + corev1.PullIfNotPresent, + corev1.PullAlways, + }, + ), + wantReason: events.ReasonForbiddenPullPolicy, + wantMessage: []string{ + `allowed: Always, IfNotPresent`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryPullPolicyDecision(tt.ref, tt.matched) + + if tt.wantNil { + if got != nil { + t.Fatalf("expected nil decision, got %#v", got) + } + + return + } + + if got == nil { + t.Fatalf("expected decision, got nil") + } + + if got.EventReason != tt.wantReason { + t.Fatalf("event reason = %q, want %q", got.EventReason, tt.wantReason) + } + + if got.Action != apirules.ActionTypeDeny { + t.Fatalf("action = %q, want %q", got.Action, apirules.ActionTypeDeny) + } + + if got.SetName != "registry" { + t.Fatalf("set name = %q, want registry", got.SetName) + } + + if got.Value.Value != tt.ref.Reference { + t.Fatalf("decision value = %q, want %q", got.Value.Value, tt.ref.Reference) + } + + if got.Value.Path != tt.ref.Path { + t.Fatalf("decision path = %q, want %q", got.Value.Path, tt.ref.Path) + } + + for _, expected := range tt.wantMessage { + if !strings.Contains(got.Message, expected) { + t.Fatalf("expected message %q to contain %q", got.Message, expected) + } + } + }) + } +} + +func TestFormatAllowedPullPolicies(t *testing.T) { + tests := []struct { + name string + policies map[corev1.PullPolicy]struct{} + want string + }{ + { + name: "nil", + policies: nil, + want: "", + }, + { + name: "empty", + policies: map[corev1.PullPolicy]struct{}{}, + want: "", + }, + { + name: "single", + policies: map[corev1.PullPolicy]struct{}{ + corev1.PullAlways: {}, + }, + want: "Always", + }, + { + name: "sorted", + policies: map[corev1.PullPolicy]struct{}{ + corev1.PullNever: {}, + corev1.PullAlways: {}, + corev1.PullIfNotPresent: {}, + }, + want: "Always, IfNotPresent, Never", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatAllowedPullPolicies(tt.policies) + if got != tt.want { + t.Fatalf("formatAllowedPullPolicies() = %q, want %q", got, tt.want) + } + }) + } +} + +func registryEnforceForTest( + action apirules.ActionType, + policies []corev1.PullPolicy, + registries ...apirules.OCIRegistry, +) *apirules.NamespaceRuleEnforceBody { + out := &apirules.NamespaceRuleEnforceBody{ + Action: action, + Workloads: apirules.NamespaceRuleEnforceWorkloadsBody{ + Registries: registries, + }, + } + + if policies == nil { + return out + } + + for i := range out.Workloads.Registries { + out.Workloads.Registries[i].Policy = policies + } + + return out +} + +func registryExactForTest(values ...string) apirules.OCIRegistry { + return apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + Exact: values, + }, + } +} + +func registryExpressionForTest(expression string) apirules.OCIRegistry { + return apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + }, + } +} + +func registryPodForTest(reference string, pullPolicy corev1.PullPolicy) *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: reference, + ImagePullPolicy: pullPolicy, + }, + }, + }, + } +} + +func compiledRegistryRuleForTest( + registry apirules.OCIRegistry, + policies []corev1.PullPolicy, +) *cache.CompiledRule { + out := &cache.CompiledRule{ + Match: registry.ExpressionMatch, + } + + if len(policies) == 0 { + return out + } + + out.AllowedPolicy = make(map[corev1.PullPolicy]struct{}, len(policies)) + for _, policy := range policies { + out.AllowedPolicy[policy] = struct{}{} + } + + return out +} + +func decisionMessageForRegistryTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/pods/validation/schedulers.go b/internal/webhook/rules/pods/validation/schedulers.go index 73a8f85dc..3628ae746 100644 --- a/internal/webhook/rules/pods/validation/schedulers.go +++ b/internal/webhook/rules/pods/validation/schedulers.go @@ -8,6 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" + rulesutils "github.com/projectcapsule/capsule/internal/webhook/rules" "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" @@ -49,6 +50,8 @@ func (h *podRules) validateSchedulers( Matched: matched, }, nil }, + RuleDescription: rulesutils.DescribeExpressionMatch, + AllowedDescription: "Allowed schedulers", }, ) } diff --git a/internal/webhook/rules/pods/validation/schedulers_test.go b/internal/webhook/rules/pods/validation/schedulers_test.go new file mode 100644 index 000000000..b8bb69f03 --- /dev/null +++ b/internal/webhook/rules/pods/validation/schedulers_test.go @@ -0,0 +1,521 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestPodRulesValidateSchedulers(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "pod without schedulerName returns empty evaluation", + pod: schedulerPodForTest(""), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest(".*"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "blank schedulerName is trimmed and skipped", + pod: schedulerPodForTest(" "), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest(".*"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "no scheduler rules returns empty evaluation", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "nil enforce body is ignored", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "allow exact scheduler", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "allow regex scheduler", + pod: schedulerPodForTest("tenant-batch"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExpressionForTest("tenant-[a-z0-9-]+"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-batch" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exp: tenant-[a-z0-9-]+`, + }, + }, + { + name: "allow exact and regex in same matcher", + pod: schedulerPodForTest("tenant-special"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + api.ExpressionMatch{ + Exact: []string{ + "default-scheduler", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "tenant-[a-z0-9-]+", + }, + }, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-special" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: default-scheduler; exp: tenant-[a-z0-9-]+`, + }, + }, + { + name: "allow miss denies scheduler missing from allowed list", + pod: schedulerPodForTest("other-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + schedulerExpressionForTest("batch-[a-z0-9-]+"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `scheduler "other-scheduler" at spec.schedulerName is not allowed by namespace rule`, + `Allowed schedulers`, + `exact: tenant-scheduler`, + `exp: batch-[a-z0-9-]+`, + }, + }, + { + name: "deny matching exact scheduler", + pod: schedulerPodForTest("unsafe-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "unsafe-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule exact: unsafe-scheduler`, + }, + }, + { + name: "default action is deny", + pod: schedulerPodForTest("unsafe-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + "", + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "unsafe-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule exact: unsafe-scheduler`, + }, + }, + { + name: "later deny overrides earlier allow", + pod: schedulerPodForTest("unsafe-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExpressionForTest(".*-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "unsafe-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule exact: unsafe-scheduler`, + }, + }, + { + name: "later allow overrides earlier deny", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest(".*-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "non matching later deny does not override earlier allow", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "audit match is observational", + pod: schedulerPodForTest("custom-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAudit, + schedulerExactForTest("custom-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `scheduler "custom-scheduler" at spec.schedulerName matched audit namespace rule`, + `matched audit rule exact: custom-scheduler`, + }, + }, + { + name: "audit does not satisfy allow list", + pod: schedulerPodForTest("custom-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAudit, + schedulerExactForTest("custom-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `scheduler "custom-scheduler" at spec.schedulerName is not allowed by namespace rule`, + `Allowed schedulers`, + `exact: tenant-scheduler`, + }, + }, + { + name: "negated exact deny matches every other scheduler", + pod: schedulerPodForTest("other-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerNegatedExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "other-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule not exact: tenant-scheduler`, + }, + }, + { + name: "negated exact deny does not match excluded scheduler", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerNegatedExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "negated regex allow matches scheduler outside regex", + pod: schedulerPodForTest("external-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerNegatedExpressionForTest("tenant-[a-z0-9-]+"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "external-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule not exp: tenant-[a-z0-9-]+`, + }, + }, + { + name: "invalid regex returns matcher error", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest("["), + ), + }, + wantErr: `scheduler: invalid rule`, + }, + { + name: "schedulerName is trimmed before evaluation", + pod: schedulerPodForTest(" tenant-scheduler "), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + }, + }, + { + name: "unsupported action returns error", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionType("invalid"), + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantErr: `scheduler: unsupported rule action "invalid"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := podRulesForTest() + + evaluation, err := h.validateSchedulers(tt.pod, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForSchedulerTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenPodScheduler { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenPodScheduler) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenPodScheduler { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenPodScheduler) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenPodScheduler { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenPodScheduler) + } + } + }) + } +} + +func schedulerEnforceForTest( + action apirules.ActionType, + schedulers ...api.ExpressionMatch, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Workloads: apirules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: schedulers, + }, + } +} + +func schedulerPodForTest(schedulerName string) *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + SchedulerName: schedulerName, + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + }, + }, + }, + } +} + +func schedulerExactForTest(values ...string) api.ExpressionMatch { + return api.ExpressionMatch{ + Exact: values, + } +} + +func schedulerExpressionForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + } +} + +func schedulerNegatedExactForTest(values ...string) api.ExpressionMatch { + return api.ExpressionMatch{ + Exact: values, + ExpressionRegex: api.ExpressionRegex{ + Negate: true, + }, + } +} + +func schedulerNegatedExpressionForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } +} + +func decisionMessageForSchedulerTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/external_name.go b/internal/webhook/rules/services/validation/external_name.go new file mode 100644 index 000000000..d58e09a1c --- /dev/null +++ b/internal/webhook/rules/services/validation/external_name.go @@ -0,0 +1,86 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *serviceRules) validateExternalNames( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if svc == nil || svc.Spec.Type != corev1.ServiceTypeExternalName { + return nil, nil + } + + if strings.TrimSpace(svc.Spec.ExternalName) == "" { + return nil, nil + } + + return evaluateServiceRules[api.ExpressionMatch]( + svc, + enforceBodies, + serviceRuleSet[api.ExpressionMatch]{ + Name: "externalName hostname", + EventReason: events.ReasonForbiddenExternalName, + Values: func(svc *corev1.Service) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: strings.TrimSpace(svc.Spec.ExternalName), + Path: "spec.externalName", + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []api.ExpressionMatch { + if enforce == nil || enforce.Services.ExternalNames == nil { + return nil + } + + return enforce.Services.ExternalNames.Hostnames + }, + Matches: func(match api.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + out := ruleengine.Match{ + Matched: matched, + MatchedValue: describeExpressionMatch(match), + } + + if matched { + out.Detail = fmt.Sprintf("%q matched hostname rule %s", value.Value, describeExpressionMatch(match)) + } + + return out, nil + }, + RuleDescription: describeExpressionMatch, + AllowedDescription: "Allowed hostnames", + }, + ) +} + +func describeExpressionMatch(match api.ExpressionMatch) string { + parts := make([]string, 0, 2) + + if len(match.Exact) > 0 { + parts = append(parts, fmt.Sprintf("exact: %s", strings.Join(match.Exact, ", "))) + } + + if match.Expression != "" { + parts = append(parts, fmt.Sprintf("exp: %s", match.Expression)) + } + + return strings.Join(parts, "; ") +} diff --git a/internal/webhook/rules/services/validation/external_name_test.go b/internal/webhook/rules/services/validation/external_name_test.go new file mode 100644 index 000000000..85e0346a3 --- /dev/null +++ b/internal/webhook/rules/services/validation/external_name_test.go @@ -0,0 +1,531 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateExternalNames(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "non ExternalName service returns nil evaluation", + svc: clusterIPServiceForExternalNameTest("cluster-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest(apirules.ActionTypeDeny, expressionMatchForTest(".*")), + }, + wantNil: true, + }, + { + name: "empty externalName returns nil evaluation", + svc: externalNameServiceForTest("empty", " "), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest(apirules.ActionTypeDeny, expressionMatchForTest(".*")), + }, + wantNil: true, + }, + { + name: "no rules allows externalName without final decision", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "allow exact hostname", + svc: externalNameServiceForTest("external", "internal.git.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + exactMatchForTest("internal.git.com"), + ), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `externalName hostname "internal.git.com" at spec.externalName is allowed by namespace rule`, + `"internal.git.com" matched hostname rule exact: internal.git.com`, + }, + }, + { + name: "allow regex hostname", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest(".*\\.example\\.com"), + ), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `externalName hostname "api.example.com" at spec.externalName is allowed by namespace rule`, + `"api.example.com" matched hostname rule exp: .*\.example\.com`, + }, + }, + { + name: "allow exact and regex in same matcher", + svc: externalNameServiceForTest("external", "combined.internal.git.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + api.ExpressionMatch{ + Exact: []string{ + "combined.internal.git.com", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "combined\\..*\\.example\\.com", + }, + }, + ), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `externalName hostname "combined.internal.git.com" at spec.externalName is allowed by namespace rule`, + `exact: combined.internal.git.com; exp: combined\..*\.example\.com`, + }, + }, + { + name: "allow miss denies and reports allowed hostnames", + svc: externalNameServiceForTest("external", "api.bad.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + exactMatchForTest("internal.git.com"), + expressionMatchForTest(".*\\.example\\.com"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `externalName hostname "api.bad.com" at spec.externalName is not allowed by namespace rule`, + `Allowed hostnames`, + `exact: internal.git.com`, + `exp: .*\.example\.com`, + }, + }, + { + name: "deny matching exact hostname", + svc: externalNameServiceForTest("external", "blocked.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + exactMatchForTest("blocked.example.com"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "blocked.example.com" at spec.externalName is denied by namespace rule`, + `"blocked.example.com" matched hostname rule exact: blocked.example.com`, + }, + }, + { + name: "later deny overrides earlier allow", + svc: externalNameServiceForTest("external", "blocked.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest(".*\\.example\\.com"), + ), + externalNameEnforceForTest( + apirules.ActionTypeDeny, + exactMatchForTest("blocked.example.com"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "blocked.example.com" at spec.externalName is denied by namespace rule`, + `"blocked.example.com" matched hostname rule exact: blocked.example.com`, + }, + }, + { + name: "later allow overrides earlier deny", + svc: externalNameServiceForTest("external", "trusted.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + expressionMatchForTest(".*\\.example\\.com"), + ), + externalNameEnforceForTest( + apirules.ActionTypeAllow, + exactMatchForTest("trusted.example.com"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "trusted.example.com" at spec.externalName is allowed by namespace rule`, + `"trusted.example.com" matched hostname rule exact: trusted.example.com`, + }, + }, + { + name: "audit match is observational", + svc: externalNameServiceForTest("external", "audit.internal"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAudit, + expressionMatchForTest("audit\\..*"), + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `externalName hostname "audit.internal" at spec.externalName matched audit namespace rule`, + `"audit.internal" matched hostname rule exp: audit\..*`, + }, + }, + { + name: "audit does not satisfy allow list", + svc: externalNameServiceForTest("external", "audit.internal"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAudit, + expressionMatchForTest("audit\\..*"), + ), + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest("allowed\\..*"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `externalName hostname "audit.internal" at spec.externalName is not allowed by namespace rule`, + `Allowed hostnames`, + `exp: allowed\..*`, + }, + }, + { + name: "negated regex deny matches untrusted hostname", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + negatedExpressionMatchForTest("trusted\\..*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "api.example.com" at spec.externalName is denied by namespace rule`, + `"api.example.com" matched hostname rule exp: trusted\..*`, + }, + }, + { + name: "negated regex deny does not match trusted hostname", + svc: externalNameServiceForTest("external", "trusted.api"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + negatedExpressionMatchForTest("trusted\\..*"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "invalid regex returns matcher error", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + expressionMatchForTest("["), + ), + }, + wantErr: `externalName hostname: invalid rule`, + }, + { + name: "nil enforce body is ignored", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest(".*\\.example\\.com"), + ), + }, + wantFinal: true, + wantBlocking: false, + }, + { + name: "enforce without externalName rules is ignored", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateExternalNames(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForExternalNameTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenExternalName { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenExternalName) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenExternalName { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenExternalName) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenExternalName { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenExternalName) + } + } + }) + } +} + +func TestDescribeExpressionMatch(t *testing.T) { + tests := []struct { + name string + match api.ExpressionMatch + want string + }{ + { + name: "empty matcher", + match: api.ExpressionMatch{}, + want: "", + }, + { + name: "exact only", + match: api.ExpressionMatch{ + Exact: []string{ + "internal.git.com", + "api.example.com", + }, + }, + want: "exact: internal.git.com, api.example.com", + }, + { + name: "expression only", + match: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }, + want: "exp: .*\\.example\\.com", + }, + { + name: "exact and expression", + match: api.ExpressionMatch{ + Exact: []string{ + "internal.git.com", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }, + want: "exact: internal.git.com; exp: .*\\.example\\.com", + }, + { + name: "negate is currently not included in description", + match: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted\\..*", + Negate: true, + }, + }, + want: "exp: trusted\\..*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeExpressionMatch(tt.match) + if got != tt.want { + t.Fatalf("describeExpressionMatch() = %q, want %q", got, tt.want) + } + }) + } +} + +func externalNameEnforceForTest( + action apirules.ActionType, + hostnames ...api.ExpressionMatch, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &apirules.ServiceExternalNameRule{ + Hostnames: hostnames, + }, + }, + } +} + +func externalNameServiceForTest(name string, externalName string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: externalName, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + }, + } +} + +func clusterIPServiceForExternalNameTest(name string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func exactMatchForTest(values ...string) api.ExpressionMatch { + return api.ExpressionMatch{ + Exact: values, + } +} + +func expressionMatchForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + } +} + +func negatedExpressionMatchForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } +} + +func decisionMessageForExternalNameTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/factory.go b/internal/webhook/rules/services/validation/factory.go new file mode 100644 index 000000000..7df917e6d --- /dev/null +++ b/internal/webhook/rules/services/validation/factory.go @@ -0,0 +1,180 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "errors" + + corev1 "k8s.io/api/core/v1" + "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/internal/cache" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type serviceRuleSet[R any] = ruleengine.Set[R, *corev1.Service] + +func evaluateServiceRules[R any]( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, + set serviceRuleSet[R], +) (*ruleengine.Evaluation, error) { + if svc == nil || len(enforceBodies) == 0 { + return nil, nil + } + + return ruleengine.EvaluateEnforce( + svc, + enforceBodies, + set, + ) +} + +type serviceRuleValidator func( + *corev1.Service, + []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) + +type serviceRules struct { + rules []serviceRuleValidator + regexCache *cache.RegexCache +} + +func ServiceRules( + regexCache *cache.RegexCache, +) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service] { + if regexCache == nil { + regexCache = cache.NewRegexCache() + } + + h := &serviceRules{ + regexCache: regexCache, + } + + h.rules = []serviceRuleValidator{ + h.validateServiceTypes, + h.validateLoadBalancers, + h.validateExternalNames, + h.validateNodePorts, + } + + return h +} + +func (h *serviceRules) OnCreate( + _ client.Client, + _ client.Reader, + svc *corev1.Service, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validateServiceRules(ctx, req, svc, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *serviceRules) OnUpdate( + _ client.Client, + _ client.Reader, + _ *corev1.Service, + svc *corev1.Service, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validateServiceRules(ctx, req, svc, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *serviceRules) OnDelete( + _ client.Client, + _ client.Reader, + _ *corev1.Service, + _ admission.Decoder, + _ events.EventRecorder, + _ *capsulev1beta2.Tenant, + _ []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *serviceRules) validateServiceRules( + ctx context.Context, + req admission.Request, + svc *corev1.Service, + tnt *capsulev1beta2.Tenant, + recorder events.EventRecorder, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) error { + for _, evaluate := range h.rules { + evaluation, err := evaluate(svc, enforceBodies) + if err != nil { + return err + } + + if evaluation == nil { + continue + } + + for _, audit := range evaluation.Audits { + recorder.LabeledEvent( + svc, + corev1.EventTypeNormal, + events.ReasonNamespaceRuleAudit, + events.ActionRuleAudit, + audit.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + if err := evaluation.BlockingError(); err != nil { + var decisionErr *ruleengine.DecisionError + if errors.As(err, &decisionErr) && decisionErr.Decision != nil { + recorder.LabeledEvent( + svc, + corev1.EventTypeWarning, + decisionErr.Decision.EventReason, + events.ActionValidationDenied, + decisionErr.Decision.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + return err + } + } + + return nil +} diff --git a/internal/webhook/rules/services/validation/factory_test.go b/internal/webhook/rules/services/validation/factory_test.go new file mode 100644 index 000000000..c752f6a78 --- /dev/null +++ b/internal/webhook/rules/services/validation/factory_test.go @@ -0,0 +1,12 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import "github.com/projectcapsule/capsule/internal/cache" + +func serviceRulesForTest() *serviceRules { + return &serviceRules{ + regexCache: cache.NewRegexCache(), + } +} diff --git a/internal/webhook/rules/services/validation/loadbalancer.go b/internal/webhook/rules/services/validation/loadbalancer.go new file mode 100644 index 000000000..34a298e54 --- /dev/null +++ b/internal/webhook/rules/services/validation/loadbalancer.go @@ -0,0 +1,204 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "net" + "strings" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +type loadBalancerCIDRRule struct { + CIDRs []string +} + +func (h *serviceRules) validateLoadBalancers( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if svc == nil || svc.Spec.Type != corev1.ServiceTypeLoadBalancer { + return nil, nil + } + + if requiresLoadBalancerCIDRs(enforceBodies) && len(loadBalancerCIDRValues(svc)) == 0 { + return &ruleengine.Evaluation{ + Blocking: &ruleengine.Decision{ + SetName: "loadBalancer CIDR", + EventReason: events.ReasonForbiddenLoadBalancerCIDR, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: string(corev1.ServiceTypeLoadBalancer), + Path: "spec.type", + }, + Message: "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges because loadBalancer CIDR constraints are enforced by namespace rule", + }, + }, nil + } + + values := loadBalancerCIDRValues(svc) + if len(values) == 0 { + return nil, nil + } + + return evaluateServiceRules[loadBalancerCIDRRule]( + svc, + enforceBodies, + serviceRuleSet[loadBalancerCIDRRule]{ + Name: "loadBalancer CIDR", + EventReason: events.ReasonForbiddenLoadBalancerCIDR, + Values: func(_ *corev1.Service) []ruleengine.Value { + return values + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []loadBalancerCIDRRule { + if enforce == nil || + enforce.Services.LoadBalancers == nil || + len(enforce.Services.LoadBalancers.CIDRs) == 0 { + return nil + } + + cidrs := make([]string, 0, len(enforce.Services.LoadBalancers.CIDRs)) + for _, cidr := range enforce.Services.LoadBalancers.CIDRs { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + + cidrs = append(cidrs, cidr) + } + + if len(cidrs) == 0 { + return nil + } + + return []loadBalancerCIDRRule{ + { + CIDRs: cidrs, + }, + } + }, + Matches: func(rule loadBalancerCIDRRule, value ruleengine.Value) (ruleengine.Match, error) { + if len(rule.CIDRs) == 0 { + return ruleengine.Match{}, nil + } + + for _, rawCIDR := range rule.CIDRs { + allowedCIDR, err := parseCIDR(rawCIDR) + if err != nil { + return ruleengine.Match{}, fmt.Errorf("invalid loadBalancer CIDR %q: %w", rawCIDR, err) + } + + if ip := net.ParseIP(value.Value); ip != nil { + if !cidrContainsIP(allowedCIDR, ip) { + continue + } + + return ruleengine.Match{ + Matched: true, + MatchedValue: rawCIDR, + Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR), + }, nil + } + + _, requestedCIDR, err := net.ParseCIDR(value.Value) + if err != nil { + return ruleengine.Match{}, fmt.Errorf( + "%s contains invalid IP or CIDR %q: %w", + value.Path, + value.Value, + err, + ) + } + + if !cidrContainsCIDR(allowedCIDR, requestedCIDR) { + continue + } + + return ruleengine.Match{ + Matched: true, + MatchedValue: rawCIDR, + Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR), + }, nil + } + + return ruleengine.Match{ + Matched: false, + }, nil + }, + RuleDescription: func(rule loadBalancerCIDRRule) string { + return strings.Join(rule.CIDRs, ", ") + }, + AllowedDescription: "Allowed CIDRs", + }, + ) +} + +func requiresLoadBalancerCIDRs( + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) bool { + for _, enforce := range enforceBodies { + if enforce == nil || + enforce.Services.LoadBalancers == nil { + continue + } + + if len(enforce.Services.LoadBalancers.CIDRs) > 0 { + return true + } + } + + return false +} + +func parseCIDR(raw string) (*net.IPNet, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("CIDR is empty") + } + + if !strings.Contains(raw, "/") { + ip := net.ParseIP(raw) + if ip == nil { + return nil, fmt.Errorf("invalid CIDR %q", raw) + } + + if ip.To4() != nil { + raw += "/32" + } else { + raw += "/128" + } + } + + _, network, err := net.ParseCIDR(raw) + if err != nil { + return nil, err + } + + return network, nil +} + +func loadBalancerCIDRValues(svc *corev1.Service) []ruleengine.Value { + out := make([]ruleengine.Value, 0, 1+len(svc.Spec.LoadBalancerSourceRanges)) + + if svc.Spec.LoadBalancerIP != "" { + out = append(out, ruleengine.Value{ + Value: svc.Spec.LoadBalancerIP, + Path: "spec.loadBalancerIP", + }) + } + + for i, sourceRange := range svc.Spec.LoadBalancerSourceRanges { + out = append(out, ruleengine.Value{ + Value: sourceRange, + Path: fmt.Sprintf("spec.loadBalancerSourceRanges[%d]", i), + }) + } + + return out +} diff --git a/internal/webhook/rules/services/validation/loadbalancer_test.go b/internal/webhook/rules/services/validation/loadbalancer_test.go new file mode 100644 index 000000000..ba46cecbc --- /dev/null +++ b/internal/webhook/rules/services/validation/loadbalancer_test.go @@ -0,0 +1,903 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "net" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateLoadBalancers(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "non LoadBalancer service returns nil evaluation", + svc: clusterIPServiceForLoadBalancerTest("cluster-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.0.0/8"), + }, + wantNil: true, + }, + { + name: "LoadBalancer without values and without CIDR rules returns nil evaluation", + svc: loadBalancerServiceForTest("lb", "", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantNil: true, + }, + { + name: "requires loadBalancerIP or source ranges when CIDR rules are configured", + svc: loadBalancerServiceForTest("lb", "", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges", + "loadBalancer CIDR constraints are enforced by namespace rule", + }, + }, + { + name: "empty CIDR entry still triggers required value check", + svc: loadBalancerServiceForTest("lb", "", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, ""), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges", + }, + }, + { + name: "allows exact IPv4 loadBalancerIP inside single IP CIDR", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.0.2" at spec.loadBalancerIP is allowed by namespace rule`, + "10.0.0.2 is contained in 10.0.0.2/32", + }, + }, + { + name: "allows IPv4 loadBalancerIP inside configured range", + svc: loadBalancerServiceForTest("lb", "10.0.1.44", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.1.44" at spec.loadBalancerIP is allowed by namespace rule`, + "10.0.1.44 is contained in 10.0.1.0/24", + }, + }, + { + name: "allows plain IPv4 rule by normalizing to host CIDR", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + "10.0.0.2 is contained in 10.0.0.2", + }, + }, + { + name: "allows IPv6 loadBalancerIP inside configured range", + svc: loadBalancerServiceForTest("lb", "2001:db8::1", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "2001:db8::/32"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "2001:db8::1" at spec.loadBalancerIP is allowed by namespace rule`, + "2001:db8::1 is contained in 2001:db8::/32", + }, + }, + { + name: "allows plain IPv6 rule by normalizing to host CIDR", + svc: loadBalancerServiceForTest("lb", "2001:db8::2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "2001:db8::2"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + "2001:db8::2 is contained in 2001:db8::2", + }, + }, + { + name: "allow miss denies loadBalancerIP outside configured CIDRs", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32", "10.0.1.0/24"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.0.2/32", + "10.0.1.0/24", + }, + }, + { + name: "allows source range fully contained in configured CIDR", + svc: loadBalancerServiceForTest("lb", "", []string{"10.0.1.0/25"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.1.0/25" at spec.loadBalancerSourceRanges[0] is allowed by namespace rule`, + "10.0.1.0/25 is contained in 10.0.1.0/24", + }, + }, + { + name: "allow miss denies source range not fully contained in configured CIDR", + svc: loadBalancerServiceForTest("lb", "", []string{"10.0.1.0/23"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.1.0/23" at spec.loadBalancerSourceRanges[0] is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.1.0/24", + }, + }, + { + name: "multiple values deny if any value misses allow list", + svc: loadBalancerServiceForTest("lb", "10.0.1.44", []string{"172.16.0.0/16"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "172.16.0.0/16" at spec.loadBalancerSourceRanges[0] is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.1.0/24", + }, + }, + { + name: "deny matching CIDR", + svc: loadBalancerServiceForTest("lb", "10.0.66.10", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.66.0/24"), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "10.0.66.10" at spec.loadBalancerIP is denied by namespace rule`, + "10.0.66.10 is contained in 10.0.66.0/24", + }, + }, + { + name: "later deny overrides earlier allow", + svc: loadBalancerServiceForTest("lb", "10.0.66.10", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.0/8"), + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.66.0/24"), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "10.0.66.10" at spec.loadBalancerIP is denied by namespace rule`, + "10.0.66.10 is contained in 10.0.66.0/24", + }, + }, + { + name: "later allow overrides earlier deny", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.0.0/8"), + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.171.0/24"), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is allowed by namespace rule`, + "10.0.171.239 is contained in 10.0.171.0/24", + }, + }, + { + name: "audit match is observational", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAudit, "10.0.171.0/24"), + }, + wantBlocking: false, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP matched audit namespace rule`, + "10.0.171.239 is contained in 10.0.171.0/24", + }, + }, + { + name: "audit does not satisfy allow list", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAudit, "10.0.171.0/24"), + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.0.2/32", + }, + }, + { + name: "invalid configured CIDR returns matcher error", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.0.0/33"), + }, + wantErr: `loadBalancer CIDR: invalid rule: invalid loadBalancer CIDR "10.0.0.0/33"`, + }, + { + name: "invalid requested source range returns matcher error", + svc: loadBalancerServiceForTest("lb", "", []string{"not-a-cidr"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.0/8"), + }, + wantErr: `loadBalancer CIDR: invalid rule: spec.loadBalancerSourceRanges[0] contains invalid IP or CIDR "not-a-cidr"`, + }, + { + name: "nil enforce body is ignored", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantFinal: true, + wantBlocking: false, + }, + { + name: "enforce without loadBalancer rules is ignored", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "empty configured CIDRs are ignored during rule extraction when values exist", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "", " "), + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "unsupported action returns error", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionType("invalid"), "10.0.0.2/32"), + }, + wantErr: `loadBalancer CIDR: unsupported rule action "invalid"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateLoadBalancers(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForLoadBalancerTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenLoadBalancerCIDR { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenLoadBalancerCIDR) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenLoadBalancerCIDR { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenLoadBalancerCIDR) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenLoadBalancerCIDR { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenLoadBalancerCIDR) + } + } + }) + } +} + +func TestRequiresLoadBalancerCIDRs(t *testing.T) { + tests := []struct { + name string + enforceBodies []*apirules.NamespaceRuleEnforceBody + want bool + }{ + { + name: "nil bodies", + enforceBodies: nil, + want: false, + }, + { + name: "nil enforce body", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + }, + want: false, + }, + { + name: "missing loadBalancer rules", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + want: false, + }, + { + name: "empty cidr list", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{}, + }, + }, + }, + want: false, + }, + { + name: "blank cidr entry still counts as configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: []string{""}, + }, + }, + }, + }, + want: true, + }, + { + name: "cidr configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: []string{"10.0.0.0/8"}, + }, + }, + }, + }, + want: true, + }, + { + name: "later cidr configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + {}, + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: []string{"10.0.0.0/8"}, + }, + }, + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := requiresLoadBalancerCIDRs(tt.enforceBodies) + if got != tt.want { + t.Fatalf("requiresLoadBalancerCIDRs() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestParseCIDR(t *testing.T) { + tests := []struct { + name string + raw string + wantNetwork string + wantErr string + }{ + { + name: "IPv4 CIDR", + raw: "10.0.0.0/8", + wantNetwork: "10.0.0.0/8", + }, + { + name: "IPv4 host", + raw: "10.0.0.2", + wantNetwork: "10.0.0.2/32", + }, + { + name: "IPv4 host with whitespace", + raw: " 10.0.0.2 ", + wantNetwork: "10.0.0.2/32", + }, + { + name: "IPv6 CIDR", + raw: "2001:db8::/32", + wantNetwork: "2001:db8::/32", + }, + { + name: "IPv6 host", + raw: "2001:db8::2", + wantNetwork: "2001:db8::2/128", + }, + { + name: "empty", + raw: "", + wantErr: "CIDR is empty", + }, + { + name: "whitespace", + raw: " ", + wantErr: "CIDR is empty", + }, + { + name: "invalid IP without slash", + raw: "not-an-ip", + wantErr: `invalid CIDR "not-an-ip"`, + }, + { + name: "invalid CIDR", + raw: "10.0.0.0/33", + wantErr: "invalid CIDR address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCIDR(tt.raw) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got == nil { + t.Fatalf("expected network, got nil") + } + + if got.String() != tt.wantNetwork { + t.Fatalf("network = %q, want %q", got.String(), tt.wantNetwork) + } + }) + } +} + +func TestLoadBalancerCIDRValues(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want []struct { + value string + path string + } + }{ + { + name: "no values", + svc: loadBalancerServiceForTest("lb", "", nil), + want: nil, + }, + { + name: "loadBalancerIP only", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + want: []struct { + value string + path string + }{ + { + value: "10.0.0.2", + path: "spec.loadBalancerIP", + }, + }, + }, + { + name: "source ranges only", + svc: loadBalancerServiceForTest("lb", "", []string{"10.0.1.0/25", "10.0.2.0/24"}), + want: []struct { + value string + path string + }{ + { + value: "10.0.1.0/25", + path: "spec.loadBalancerSourceRanges[0]", + }, + { + value: "10.0.2.0/24", + path: "spec.loadBalancerSourceRanges[1]", + }, + }, + }, + { + name: "loadBalancerIP and source ranges", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", []string{"10.0.1.0/25"}), + want: []struct { + value string + path string + }{ + { + value: "10.0.0.2", + path: "spec.loadBalancerIP", + }, + { + value: "10.0.1.0/25", + path: "spec.loadBalancerSourceRanges[0]", + }, + }, + }, + { + name: "blank source range is preserved for matcher validation", + svc: loadBalancerServiceForTest("lb", "", []string{" "}), + want: []struct { + value string + path string + }{ + { + value: " ", + path: "spec.loadBalancerSourceRanges[0]", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := loadBalancerCIDRValues(tt.svc) + + if len(got) != len(tt.want) { + t.Fatalf("expected %d values, got %d: %#v", len(tt.want), len(got), got) + } + + for i, want := range tt.want { + if got[i].Value != want.value { + t.Fatalf("value[%d] = %q, want %q", i, got[i].Value, want.value) + } + + if got[i].Path != want.path { + t.Fatalf("path[%d] = %q, want %q", i, got[i].Path, want.path) + } + } + }) + } +} + +func TestCIDRContainsHelpers(t *testing.T) { + _, allowedIPv4, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedInsideIPv4, err := net.ParseCIDR("10.0.1.0/24") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedOutsideIPv4, err := net.ParseCIDR("10.1.0.0/7") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, allowedIPv6, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedInsideIPv6, err := net.ParseCIDR("2001:db8:1::/48") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedOutsideIPv6, err := net.ParseCIDR("2001:db9::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + tests := []struct { + name string + fn func() bool + want bool + }{ + { + name: "IPv4 contains IP", + fn: func() bool { + return cidrContainsIP(allowedIPv4, net.ParseIP("10.0.0.2")) + }, + want: true, + }, + { + name: "IPv4 does not contain IP", + fn: func() bool { + return cidrContainsIP(allowedIPv4, net.ParseIP("192.168.0.1")) + }, + want: false, + }, + { + name: "nil IP is not contained", + fn: func() bool { + return cidrContainsIP(allowedIPv4, nil) + }, + want: false, + }, + { + name: "nil CIDR does not contain IP", + fn: func() bool { + return cidrContainsIP(nil, net.ParseIP("10.0.0.2")) + }, + want: false, + }, + { + name: "IPv4 contains requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, requestedInsideIPv4) + }, + want: true, + }, + { + name: "IPv4 does not fully contain requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, requestedOutsideIPv4) + }, + want: false, + }, + { + name: "nil allowed CIDR does not contain CIDR", + fn: func() bool { + return cidrContainsCIDR(nil, requestedInsideIPv4) + }, + want: false, + }, + { + name: "nil requested CIDR is not contained", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, nil) + }, + want: false, + }, + { + name: "IPv6 contains IP", + fn: func() bool { + return cidrContainsIP(allowedIPv6, net.ParseIP("2001:db8::1")) + }, + want: true, + }, + { + name: "IPv6 does not contain IP", + fn: func() bool { + return cidrContainsIP(allowedIPv6, net.ParseIP("2001:db9::1")) + }, + want: false, + }, + { + name: "IPv6 contains requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv6, requestedInsideIPv6) + }, + want: true, + }, + { + name: "IPv6 does not contain requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv6, requestedOutsideIPv6) + }, + want: false, + }, + { + name: "IPv4 CIDR does not contain IPv6 IP", + fn: func() bool { + return cidrContainsIP(allowedIPv4, net.ParseIP("2001:db8::1")) + }, + want: false, + }, + { + name: "IPv4 CIDR does not contain IPv6 CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, requestedInsideIPv6) + }, + want: false, + }, + { + name: "IPv6 CIDR does not contain IPv4 IP", + fn: func() bool { + return cidrContainsIP(allowedIPv6, net.ParseIP("10.0.0.2")) + }, + want: false, + }, + { + name: "IPv6 CIDR does not contain IPv4 CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv6, requestedInsideIPv4) + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.fn() + if got != tt.want { + t.Fatalf("got %t, want %t", got, tt.want) + } + }) + } +} + +func loadBalancerEnforceForTest( + action apirules.ActionType, + cidrs ...string, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: cidrs, + }, + }, + } +} + +func loadBalancerServiceForTest( + name string, + loadBalancerIP string, + sourceRanges []string, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + LoadBalancerIP: loadBalancerIP, + LoadBalancerSourceRanges: sourceRanges, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func clusterIPServiceForLoadBalancerTest(name string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func decisionMessageForLoadBalancerTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/node_port.go b/internal/webhook/rules/services/validation/node_port.go new file mode 100644 index 000000000..89d66bf80 --- /dev/null +++ b/internal/webhook/rules/services/validation/node_port.go @@ -0,0 +1,134 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *serviceRules) validateNodePorts( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if svc == nil || !serviceTypeIsNodePort(svc) { + return nil, nil + } + + if requiresNodePortRanges(enforceBodies) && len(nodePortValues(svc)) == 0 { + return &ruleengine.Evaluation{ + Blocking: &ruleengine.Decision{ + SetName: "nodePort", + EventReason: events.ReasonForbiddenNodePort, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: string(svc.Spec.Type), + Path: "spec.type", + }, + Message: "service requires explicit spec.ports[*].nodePort because nodePort ranges are enforced by namespace rule", + }, + }, nil + } + + values := nodePortValues(svc) + if len(values) == 0 { + return nil, nil + } + + return evaluateServiceRules[apirules.ServiceNodePortRange]( + svc, + enforceBodies, + serviceRuleSet[apirules.ServiceNodePortRange]{ + Name: "nodePort", + EventReason: events.ReasonForbiddenNodePort, + Values: func(_ *corev1.Service) []ruleengine.Value { + return values + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []apirules.ServiceNodePortRange { + if enforce == nil || enforce.Services.NodePorts == nil { + return nil + } + + return enforce.Services.NodePorts.Ports + }, + Matches: func(r apirules.ServiceNodePortRange, value ruleengine.Value) (ruleengine.Match, error) { + if r.From > r.To { + return ruleengine.Match{}, fmt.Errorf( + "invalid nodePort range: from %d must be lower than or equal to %d", + r.From, + r.To, + ) + } + + port, err := portFromValue(value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + matched := port >= r.From && port <= r.To + + match := ruleengine.Match{ + Matched: matched, + MatchedValue: describeNodePortRange(r), + } + + if matched { + match.Detail = fmt.Sprintf("nodePort %d is within allowed range %s", port, describeNodePortRange(r)) + } + + return match, nil + }, + RuleDescription: describeNodePortRange, + AllowedDescription: "Allowed ranges", + }, + ) +} + +func describeNodePortRange(r apirules.ServiceNodePortRange) string { + if r.From == r.To { + return fmt.Sprintf("%d", r.From) + } + + return fmt.Sprintf("%d-%d", r.From, r.To) +} + +func nodePortValues(svc *corev1.Service) []ruleengine.Value { + out := make([]ruleengine.Value, 0, len(svc.Spec.Ports)) + + for i := range svc.Spec.Ports { + port := svc.Spec.Ports[i] + if port.NodePort == 0 { + continue + } + + out = append(out, ruleengine.Value{ + Value: fmt.Sprintf("%d", port.NodePort), + Path: fmt.Sprintf("spec.ports[%d].nodePort", i), + }) + } + + return out +} + +func requiresNodePortRanges( + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) bool { + for _, enforce := range enforceBodies { + if enforce == nil || + enforce.Services.NodePorts == nil { + continue + } + + if len(enforce.Services.NodePorts.Ports) > 0 { + return true + } + } + + return false +} diff --git a/internal/webhook/rules/services/validation/node_port_test.go b/internal/webhook/rules/services/validation/node_port_test.go new file mode 100644 index 000000000..d31dbe4f1 --- /dev/null +++ b/internal/webhook/rules/services/validation/node_port_test.go @@ -0,0 +1,756 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateNodePorts(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "ClusterIP service returns nil evaluation", + svc: clusterIPServiceForNodePortTest("cluster-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + }, + wantNil: true, + }, + { + name: "ExternalName service returns nil evaluation", + svc: externalNameServiceForNodePortTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + }, + wantNil: true, + }, + { + name: "LoadBalancer with nodePort allocation disabled returns nil evaluation", + svc: loadBalancerServiceForNodePortTest("lb", false, 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + }, + wantNil: true, + }, + { + name: "NodePort without values and without nodePort rules returns nil evaluation", + svc: nodePortServiceForTest("node-port", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantNil: true, + }, + { + name: "requires explicit nodePort when ranges are configured", + svc: nodePortServiceForTest("node-port", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "service requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + }, + { + name: "requires explicit LoadBalancer nodePort when allocation is enabled and ranges are configured", + svc: loadBalancerServiceForNodePortTest("lb", true, 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "service requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + }, + { + name: "requires explicit LoadBalancer nodePort when allocation default is enabled and ranges are configured", + svc: loadBalancerServiceForNodePortTestWithDefaultAllocation("lb", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "service requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + }, + { + name: "allows explicit nodePort inside range", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30080" at spec.ports[0].nodePort is allowed by namespace rule`, + "nodePort 30080 is within allowed range 30000-30100", + }, + }, + { + name: "allows explicit nodePort equal to single-port range", + svc: nodePortServiceForTest("node-port", 30500), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30500, 30500)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30500" at spec.ports[0].nodePort is allowed by namespace rule`, + "nodePort 30500 is within allowed range 30500", + }, + }, + { + name: "allow miss denies nodePort outside configured ranges", + svc: nodePortServiceForTest("node-port", 32080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest( + apirules.ActionTypeAllow, + nodePortRangeForTest(30000, 30100), + nodePortRangeForTest(30500, 30500), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `nodePort "32080" at spec.ports[0].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30000-30100", + "30500", + }, + }, + { + name: "deny matching nodePort", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is denied by namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "later deny overrides earlier allow", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is denied by namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "later allow overrides earlier deny", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is allowed by namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "audit match is observational", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAudit, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: false, + wantFinal: false, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort matched audit namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "audit does not satisfy allow list", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAudit, nodePortRangeForTest(30090, 30090)), + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30500, 30500)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30500", + }, + }, + { + name: "LoadBalancer with allocation enabled validates nodePort", + svc: loadBalancerServiceForNodePortTest("lb", true, 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30080" at spec.ports[0].nodePort is allowed by namespace rule`, + }, + }, + { + name: "LoadBalancer with default allocation validates nodePort", + svc: loadBalancerServiceForNodePortTestWithDefaultAllocation("lb", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30080" at spec.ports[0].nodePort is allowed by namespace rule`, + }, + }, + { + name: "LoadBalancer with allocation enabled denies nodePort outside range", + svc: loadBalancerServiceForNodePortTest("lb", true, 32080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `nodePort "32080" at spec.ports[0].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30000-30100", + }, + }, + { + name: "multiple ports deny if one nodePort misses allow list", + svc: nodePortServiceWithPortsForTest( + "node-port", + []int32{30080, 32080}, + ), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `nodePort "32080" at spec.ports[1].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30000-30100", + }, + }, + { + name: "multiple ports with zero nodePort skips zero value", + svc: nodePortServiceWithPortsForTest( + "node-port", + []int32{0, 30080}, + ), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `nodePort "30080" at spec.ports[1].nodePort is allowed by namespace rule`, + }, + }, + { + name: "invalid configured range returns matcher error", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30100, 30000)), + }, + wantErr: `nodePort: invalid rule: invalid nodePort range: from 30100 must be lower than or equal to to 30000`, + }, + { + name: "unsupported action returns error", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionType("invalid"), nodePortRangeForTest(30000, 30100)), + }, + wantErr: `nodePort: unsupported rule action "invalid"`, + }, + { + name: "nil enforce body is ignored", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + }, + { + name: "enforce without nodePort rules is ignored", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "empty nodePort range list does not require explicit nodePort", + svc: nodePortServiceForTest("node-port", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + Services: apirules.NamespaceRuleEnforceServicesBody{ + NodePorts: &apirules.ServiceNodePortRule{}, + }, + }, + }, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateNodePorts(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForNodePortTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenNodePort { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenNodePort) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenNodePort { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenNodePort) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenNodePort { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenNodePort) + } + } + }) + } +} + +func TestDescribeNodePortRange(t *testing.T) { + tests := []struct { + name string + in apirules.ServiceNodePortRange + want string + }{ + { + name: "range", + in: nodePortRangeForTest(30000, 30100), + want: "30000-30100", + }, + { + name: "single port", + in: nodePortRangeForTest(30500, 30500), + want: "30500", + }, + { + name: "invalid range still describes values", + in: nodePortRangeForTest(30100, 30000), + want: "30100-30000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeNodePortRange(tt.in) + if got != tt.want { + t.Fatalf("describeNodePortRange() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNodePortValues(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want []struct { + value string + path string + } + }{ + { + name: "no ports", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: nil, + }, + }, + want: nil, + }, + { + name: "zero nodePort is skipped", + svc: nodePortServiceForTest("node-port", 0), + want: nil, + }, + { + name: "single nodePort", + svc: nodePortServiceForTest("node-port", 30080), + want: []struct { + value string + path string + }{ + { + value: "30080", + path: "spec.ports[0].nodePort", + }, + }, + }, + { + name: "multiple nodePorts preserve index path and skip zero", + svc: nodePortServiceWithPortsForTest( + "node-port", + []int32{30080, 0, 30500}, + ), + want: []struct { + value string + path string + }{ + { + value: "30080", + path: "spec.ports[0].nodePort", + }, + { + value: "30500", + path: "spec.ports[2].nodePort", + }, + }, + }, + { + name: "LoadBalancer nodePorts are extracted too", + svc: loadBalancerServiceForNodePortTest("lb", true, 30080), + want: []struct { + value string + path string + }{ + { + value: "30080", + path: "spec.ports[0].nodePort", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nodePortValues(tt.svc) + + if len(got) != len(tt.want) { + t.Fatalf("expected %d values, got %d: %#v", len(tt.want), len(got), got) + } + + for i, want := range tt.want { + if got[i].Value != want.value { + t.Fatalf("value[%d] = %q, want %q", i, got[i].Value, want.value) + } + + if got[i].Path != want.path { + t.Fatalf("path[%d] = %q, want %q", i, got[i].Path, want.path) + } + } + }) + } +} + +func TestRequiresNodePortRanges(t *testing.T) { + tests := []struct { + name string + enforceBodies []*apirules.NamespaceRuleEnforceBody + want bool + }{ + { + name: "nil bodies", + enforceBodies: nil, + want: false, + }, + { + name: "nil enforce body", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + }, + want: false, + }, + { + name: "missing nodePort rules", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + want: false, + }, + { + name: "empty ports list", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + NodePorts: &apirules.ServiceNodePortRule{}, + }, + }, + }, + want: false, + }, + { + name: "range configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + want: true, + }, + { + name: "later range configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + {}, + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + want: true, + }, + { + name: "invalid range still counts as configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30100, 30000)), + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := requiresNodePortRanges(tt.enforceBodies) + if got != tt.want { + t.Fatalf("requiresNodePortRanges() = %t, want %t", got, tt.want) + } + }) + } +} + +func nodePortEnforceForTest( + action apirules.ActionType, + ports ...apirules.ServiceNodePortRange, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + NodePorts: &apirules.ServiceNodePortRule{ + Ports: ports, + }, + }, + } +} + +func nodePortRangeForTest(from int32, to int32) apirules.ServiceNodePortRange { + return apirules.ServiceNodePortRange{ + From: from, + To: to, + } +} + +func nodePortServiceForTest(name string, nodePort int32) *corev1.Service { + return nodePortServiceWithPortsForTest(name, []int32{nodePort}) +} + +func nodePortServiceWithPortsForTest(name string, nodePorts []int32) *corev1.Service { + ports := make([]corev1.ServicePort, 0, len(nodePorts)) + + for i, nodePort := range nodePorts { + ports = append(ports, corev1.ServicePort{ + Name: "port-" + string(rune('a'+i)), + Port: int32(8080 + i), + TargetPort: intstr.FromInt(8080 + i), + NodePort: nodePort, + }) + } + + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: ports, + }, + } +} + +func loadBalancerServiceForNodePortTest( + name string, + allocate bool, + nodePort int32, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + AllocateLoadBalancerNodePorts: &allocate, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + NodePort: nodePort, + }, + }, + }, + } +} + +func loadBalancerServiceForNodePortTestWithDefaultAllocation( + name string, + nodePort int32, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + NodePort: nodePort, + }, + }, + }, + } +} + +func clusterIPServiceForNodePortTest(name string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func externalNameServiceForNodePortTest(name string, externalName string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: externalName, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + }, + } +} + +func decisionMessageForNodePortTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/service_type.go b/internal/webhook/rules/services/validation/service_type.go new file mode 100644 index 000000000..172dd37f3 --- /dev/null +++ b/internal/webhook/rules/services/validation/service_type.go @@ -0,0 +1,61 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *serviceRules) validateServiceTypes( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + return evaluateServiceRules[apirules.ServiceType]( + svc, + enforceBodies, + serviceRuleSet[apirules.ServiceType]{ + Name: "service type", + EventReason: events.ReasonForbiddenServiceType, + Values: func(svc *corev1.Service) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: string(serviceType(svc)), + Path: "spec.type", + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []apirules.ServiceType { + if enforce == nil { + return nil + } + + return enforce.Services.Types + }, + Matches: func(rule apirules.ServiceType, value ruleengine.Value) (ruleengine.Match, error) { + matched := string(rule) == value.Value + + match := ruleengine.Match{ + Matched: matched, + MatchedValue: rule, + } + + if matched { + match.Detail = fmt.Sprintf("service type %q matched %q", value.Value, rule) + } + + return match, nil + }, + RuleDescription: func(rule apirules.ServiceType) string { + return string(rule) + }, + AllowedDescription: "Allowed service types", + }, + ) +} diff --git a/internal/webhook/rules/services/validation/service_type_test.go b/internal/webhook/rules/services/validation/service_type_test.go new file mode 100644 index 000000000..c447c3f2f --- /dev/null +++ b/internal/webhook/rules/services/validation/service_type_test.go @@ -0,0 +1,436 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateServiceTypes(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantErr string + wantMessage []string + }{ + { + name: "ClusterIP service without rules returns empty evaluation", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "nil enforce body is ignored", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "allow ClusterIP service type", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "allow NodePort service type", + svc: serviceTypeServiceForTest("node-port", corev1.ServiceTypeNodePort), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeNodePort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "NodePort" at spec.type is allowed by namespace rule`, + `service type "NodePort" matched "NodePort"`, + }, + }, + { + name: "allow LoadBalancer service type", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is allowed by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "allow ExternalName service type", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeExternalName, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ExternalName" at spec.type is allowed by namespace rule`, + `service type "ExternalName" matched "ExternalName"`, + }, + }, + { + name: "empty Kubernetes service type is treated as ClusterIP", + svc: serviceTypeServiceForTest("default-cluster-ip", corev1.ServiceType("")), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "allow miss denies service type missing from allowed list", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type is not allowed by namespace rule`, + "Allowed service types", + "ClusterIP", + }, + }, + { + name: "allow miss reports multiple allowed service types", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + apirules.ServiceTypeNodePort, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type is not allowed by namespace rule`, + "Allowed service types", + "ClusterIP", + "NodePort", + "LoadBalancer", + }, + }, + { + name: "deny matching service type", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is denied by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "default action is deny", + svc: serviceTypeServiceForTest("node-port", corev1.ServiceTypeNodePort), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + "", + apirules.ServiceTypeNodePort, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `service type "NodePort" at spec.type is denied by namespace rule`, + `service type "NodePort" matched "NodePort"`, + }, + }, + { + name: "later deny overrides earlier allow", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeLoadBalancer, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is denied by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "later allow overrides earlier deny", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is allowed by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "non matching later deny does not override earlier allow", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "audit match is observational", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAudit, + apirules.ServiceTypeExternalName, + ), + }, + wantBlocking: false, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type matched audit namespace rule`, + `service type "ExternalName" matched "ExternalName"`, + }, + }, + { + name: "audit does not satisfy allow list", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAudit, + apirules.ServiceTypeExternalName, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type is not allowed by namespace rule`, + "Allowed service types", + "ClusterIP", + }, + }, + { + name: "unsupported action returns error", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionType("invalid"), + apirules.ServiceTypeClusterIP, + ), + }, + wantErr: `service type: unsupported rule action "invalid"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateServiceTypes(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForServiceTypeTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenServiceType { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenServiceType) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenServiceType { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenServiceType) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenServiceType { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenServiceType) + } + } + }) + } +} + +func serviceTypeEnforceForTest( + action apirules.ActionType, + types ...apirules.ServiceType, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + Types: types, + }, + } +} + +func serviceTypeServiceForTest( + name string, + serviceType corev1.ServiceType, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: serviceType, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func decisionMessageForServiceTypeTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/utils.go b/internal/webhook/rules/services/validation/utils.go new file mode 100644 index 000000000..733279d58 --- /dev/null +++ b/internal/webhook/rules/services/validation/utils.go @@ -0,0 +1,105 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "math/big" + "net" + "strconv" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func serviceType(svc *corev1.Service) apirules.ServiceType { + if svc == nil { + return "" + } + + switch svc.Spec.Type { + case "", corev1.ServiceTypeClusterIP: + return apirules.ServiceTypeClusterIP + case corev1.ServiceTypeNodePort: + return apirules.ServiceTypeNodePort + case corev1.ServiceTypeLoadBalancer: + return apirules.ServiceTypeLoadBalancer + case corev1.ServiceTypeExternalName: + return apirules.ServiceTypeExternalName + default: + return apirules.ServiceType(svc.Spec.Type) + } +} + +//nolint:exhaustive +func serviceTypeIsNodePort(svc *corev1.Service) bool { + if svc == nil { + return false + } + + switch svc.Spec.Type { + case corev1.ServiceTypeNodePort: + return true + + case corev1.ServiceTypeLoadBalancer: + return svc.Spec.AllocateLoadBalancerNodePorts == nil || + *svc.Spec.AllocateLoadBalancerNodePorts + + default: + return false + } +} + +func cidrContainsIP(network *net.IPNet, ip net.IP) bool { + if network == nil || ip == nil { + return false + } + + return network.Contains(ip) +} + +func cidrContainsCIDR(parent, child *net.IPNet) bool { + if parent == nil || child == nil { + return false + } + + first := child.IP + last := lastIP(child) + + return parent.Contains(first) && parent.Contains(last) +} + +func lastIP(network *net.IPNet) net.IP { + ip := network.IP + mask := network.Mask + + ipInt := big.NewInt(0).SetBytes(ip) + maskInt := big.NewInt(0).SetBytes(mask) + + bits := uint(len(mask) * 8) + + allOnes := big.NewInt(0).Sub( + big.NewInt(0).Lsh(big.NewInt(1), bits), + big.NewInt(1), + ) + + invertedMask := big.NewInt(0).Xor(maskInt, allOnes) + + last := big.NewInt(0).Or(ipInt, invertedMask).Bytes() + + out := make(net.IP, len(ip)) + copy(out[len(out)-len(last):], last) + + return out +} + +func portFromValue(value string) (int32, error) { + port, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid nodePort value %q: %w", value, err) + } + + return int32(port), nil +} diff --git a/internal/webhook/rules/services/validation/utils_test.go b/internal/webhook/rules/services/validation/utils_test.go new file mode 100644 index 000000000..364096b24 --- /dev/null +++ b/internal/webhook/rules/services/validation/utils_test.go @@ -0,0 +1,528 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "net" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestServiceType(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want apirules.ServiceType + }{ + { + name: "nil service", + svc: nil, + want: "", + }, + { + name: "empty service type is treated as ClusterIP", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{}, + }, + want: apirules.ServiceTypeClusterIP, + }, + { + name: "ClusterIP", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + }, + }, + want: apirules.ServiceTypeClusterIP, + }, + { + name: "NodePort", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }, + }, + want: apirules.ServiceTypeNodePort, + }, + { + name: "LoadBalancer", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + }, + want: apirules.ServiceTypeLoadBalancer, + }, + { + name: "ExternalName", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + }, + }, + want: apirules.ServiceTypeExternalName, + }, + { + name: "unknown type is preserved", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceType("CustomType"), + }, + }, + want: apirules.ServiceType("CustomType"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := serviceType(tt.svc) + if got != tt.want { + t.Fatalf("serviceType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestServiceTypeIsNodePort(t *testing.T) { + enabled := true + disabled := false + + tests := []struct { + name string + svc *corev1.Service + want bool + }{ + { + name: "nil service", + svc: nil, + want: false, + }, + { + name: "ClusterIP", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + }, + }, + want: false, + }, + { + name: "ExternalName", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + }, + }, + want: false, + }, + { + name: "NodePort", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }, + }, + want: true, + }, + { + name: "LoadBalancer allocation default", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + }, + want: true, + }, + { + name: "LoadBalancer allocation explicitly enabled", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + AllocateLoadBalancerNodePorts: &enabled, + }, + }, + want: true, + }, + { + name: "LoadBalancer allocation explicitly disabled", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + AllocateLoadBalancerNodePorts: &disabled, + }, + }, + want: false, + }, + { + name: "unknown service type", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceType("CustomType"), + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := serviceTypeIsNodePort(tt.svc) + if got != tt.want { + t.Fatalf("serviceTypeIsNodePort() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestCIDRContainsIP(t *testing.T) { + _, allowedIPv4, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, allowedIPv6, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + tests := []struct { + name string + network *net.IPNet + ip net.IP + want bool + }{ + { + name: "nil network", + network: nil, + ip: net.ParseIP("10.0.0.2"), + want: false, + }, + { + name: "nil IP", + network: allowedIPv4, + ip: nil, + want: false, + }, + { + name: "IPv4 contains IP", + network: allowedIPv4, + ip: net.ParseIP("10.0.0.2"), + want: true, + }, + { + name: "IPv4 does not contain IP", + network: allowedIPv4, + ip: net.ParseIP("192.168.0.1"), + want: false, + }, + { + name: "IPv4 network does not contain IPv6 IP", + network: allowedIPv4, + ip: net.ParseIP("2001:db8::1"), + want: false, + }, + { + name: "IPv6 contains IP", + network: allowedIPv6, + ip: net.ParseIP("2001:db8::1"), + want: true, + }, + { + name: "IPv6 does not contain IP", + network: allowedIPv6, + ip: net.ParseIP("2001:db9::1"), + want: false, + }, + { + name: "IPv6 network does not contain IPv4 IP", + network: allowedIPv6, + ip: net.ParseIP("10.0.0.2"), + want: false, + }, + { + name: "invalid parsed IP", + network: allowedIPv4, + ip: net.ParseIP("not-an-ip"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cidrContainsIP(tt.network, tt.ip) + if got != tt.want { + t.Fatalf("cidrContainsIP() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestCIDRContainsCIDR(t *testing.T) { + _, allowedIPv4, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4Inside, err := net.ParseCIDR("10.0.1.0/24") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4Exact, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4PartialOutside, err := net.ParseCIDR("10.0.0.0/7") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4Outside, err := net.ParseCIDR("192.168.0.0/16") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, allowedIPv6, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv6Inside, err := net.ParseCIDR("2001:db8:1::/48") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv6Exact, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv6Outside, err := net.ParseCIDR("2001:db9::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + tests := []struct { + name string + parent *net.IPNet + child *net.IPNet + want bool + }{ + { + name: "nil parent", + parent: nil, + child: childIPv4Inside, + want: false, + }, + { + name: "nil child", + parent: allowedIPv4, + child: nil, + want: false, + }, + { + name: "IPv4 parent contains child", + parent: allowedIPv4, + child: childIPv4Inside, + want: true, + }, + { + name: "IPv4 parent contains exact child", + parent: allowedIPv4, + child: childIPv4Exact, + want: true, + }, + { + name: "IPv4 parent does not fully contain wider child", + parent: allowedIPv4, + child: childIPv4PartialOutside, + want: false, + }, + { + name: "IPv4 parent does not contain outside child", + parent: allowedIPv4, + child: childIPv4Outside, + want: false, + }, + { + name: "IPv4 parent does not contain IPv6 child", + parent: allowedIPv4, + child: childIPv6Inside, + want: false, + }, + { + name: "IPv6 parent contains child", + parent: allowedIPv6, + child: childIPv6Inside, + want: true, + }, + { + name: "IPv6 parent contains exact child", + parent: allowedIPv6, + child: childIPv6Exact, + want: true, + }, + { + name: "IPv6 parent does not contain outside child", + parent: allowedIPv6, + child: childIPv6Outside, + want: false, + }, + { + name: "IPv6 parent does not contain IPv4 child", + parent: allowedIPv6, + child: childIPv4Inside, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cidrContainsCIDR(tt.parent, tt.child) + if got != tt.want { + t.Fatalf("cidrContainsCIDR() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestLastIP(t *testing.T) { + tests := []struct { + name string + cidr string + want string + }{ + { + name: "IPv4 /24", + cidr: "10.0.1.0/24", + want: "10.0.1.255", + }, + { + name: "IPv4 /32", + cidr: "10.0.1.44/32", + want: "10.0.1.44", + }, + { + name: "IPv4 /8", + cidr: "10.0.0.0/8", + want: "10.255.255.255", + }, + { + name: "IPv6 /32", + cidr: "2001:db8::/32", + want: "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", + }, + { + name: "IPv6 /128", + cidr: "2001:db8::2/128", + want: "2001:db8::2", + }, + { + name: "IPv6 /48", + cidr: "2001:db8:1::/48", + want: "2001:db8:1:ffff:ffff:ffff:ffff:ffff", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, network, err := net.ParseCIDR(tt.cidr) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + got := lastIP(network) + + if got.String() != tt.want { + t.Fatalf("lastIP(%q) = %q, want %q", tt.cidr, got.String(), tt.want) + } + }) + } +} + +func TestPortFromValue(t *testing.T) { + tests := []struct { + name string + value string + want int32 + wantErr string + }{ + { + name: "valid port", + value: "30080", + want: 30080, + }, + { + name: "zero is parsed", + value: "0", + want: 0, + }, + { + name: "negative is parsed", + value: "-1", + want: -1, + }, + { + name: "max int32 is parsed", + value: "2147483647", + want: 2147483647, + }, + { + name: "above int32 returns error", + value: "2147483648", + wantErr: `invalid nodePort value "2147483648"`, + }, + { + name: "empty returns error", + value: "", + wantErr: `invalid nodePort value ""`, + }, + { + name: "non numeric returns error", + value: "not-a-port", + wantErr: `invalid nodePort value "not-a-port"`, + }, + { + name: "decimal returns error", + value: "30080.5", + wantErr: `invalid nodePort value "30080.5"`, + }, + { + name: "whitespace returns error", + value: " 30080 ", + wantErr: `invalid nodePort value " 30080 "`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := portFromValue(tt.value) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got != tt.want { + t.Fatalf("portFromValue() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/webhook/rules/status/validation.go b/internal/webhook/rules/status/validation.go new file mode 100644 index 000000000..f18a28c66 --- /dev/null +++ b/internal/webhook/rules/status/validation.go @@ -0,0 +1,80 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "context" + + "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/ruleengine" + 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" +) + +type ruleStatusHandler struct { + configuration configuration.Configuration +} + +func RuleStatusValidationHandler(configuration configuration.Configuration) handlers.Handler { + return &ruleStatusHandler{ + configuration: configuration, + } +} + +func (r *ruleStatusHandler) OnCreate( + _ client.Client, + _ client.Reader, + decoder admission.Decoder, + _ events.EventRecorder, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + rs := &capsulev1beta2.RuleStatus{} + if err := decoder.Decode(req, rs); err != nil { + return ad.ErroredResponse(err) + } + + return r.handle(rs) + } +} + +func (r *ruleStatusHandler) OnDelete( + client.Client, + client.Reader, + admission.Decoder, + events.EventRecorder, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (r *ruleStatusHandler) OnUpdate( + _ client.Client, + _ client.Reader, + decoder admission.Decoder, + recorder events.EventRecorder, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + rs := &capsulev1beta2.RuleStatus{} + if err := decoder.Decode(req, rs); err != nil { + return ad.ErroredResponse(err) + } + + return r.handle(rs) + } +} + +func (r *ruleStatusHandler) handle(rs *capsulev1beta2.RuleStatus) *admission.Response { + err := ruleengine.ValidateRuleStatusBody(rs.Spec) + if err != nil { + return ad.Deny(err.Error()) + } + + return nil +} diff --git a/internal/webhook/rules/utils.go b/internal/webhook/rules/utils.go new file mode 100644 index 000000000..7d4ba4b2b --- /dev/null +++ b/internal/webhook/rules/utils.go @@ -0,0 +1,34 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "strings" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func DescribeExpressionMatch(match api.ExpressionMatch) string { + parts := make([]string, 0, 3) + + prefix := "" + if match.Negate { + prefix = "not " + } + + if len(match.Exact) > 0 { + parts = append(parts, fmt.Sprintf("%sexact: %s", prefix, strings.Join(match.Exact, ", "))) + } + + if match.Expression != "" { + parts = append(parts, fmt.Sprintf("%sexp: %s", prefix, match.Expression)) + } + + if len(parts) == 0 && match.Negate { + return "not " + } + + return strings.Join(parts, "; ") +} diff --git a/internal/webhook/service/handler.go b/internal/webhook/service/handler.go index 055f90492..0601e4682 100644 --- a/internal/webhook/service/handler.go +++ b/internal/webhook/service/handler.go @@ -9,8 +9,8 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Handler(handler ...handlers.TypedHandlerWithTenant[*corev1.Service]) handlers.Handler { - return &handlers.TypedTenantHandler[*corev1.Service]{ +func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service]) handlers.Handler { + return &handlers.TypedTenantWithRulesetHandler[*corev1.Service]{ Factory: func() *corev1.Service { return &corev1.Service{} }, diff --git a/internal/webhook/service/validating.go b/internal/webhook/service/validating.go index ff0f18efd..a037445c7 100644 --- a/internal/webhook/service/validating.go +++ b/internal/webhook/service/validating.go @@ -17,6 +17,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" + "github.com/projectcapsule/capsule/pkg/api/rules" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" @@ -24,7 +25,7 @@ import ( type validating struct{} -func Validating() handlers.TypedHandlerWithTenant[*corev1.Service] { +func Validating() handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service] { return &validating{} } @@ -35,6 +36,7 @@ func (h *validating) OnCreate( decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handle(ctx, req, recorder, svc, tnt) @@ -49,6 +51,7 @@ func (h *validating) OnUpdate( decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handle(ctx, req, recorder, svc, tnt) @@ -62,6 +65,7 @@ func (h *validating) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go index cfdb6d9d6..07b46b057 100644 --- a/internal/webhook/tenant/validation/rule_validator.go +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -5,13 +5,13 @@ package validation import ( "context" - "regexp" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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/api/rules" + "github.com/projectcapsule/capsule/pkg/ruleengine" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" @@ -31,7 +31,7 @@ func (h *RuleValidationHandler) OnCreate( _ events.EventRecorder, ) handlers.Func { return func(_ context.Context, req admission.Request) *admission.Response { - if err := ValidateRule(tnt, req); err != nil { + if err := h.handle(tnt, req); err != nil { return err } @@ -60,7 +60,7 @@ func (h *RuleValidationHandler) OnUpdate( _ events.EventRecorder, ) handlers.Func { return func(_ context.Context, req admission.Request) *admission.Response { - if response := ValidateRule(tnt, req); response != nil { + if response := h.handle(tnt, req); response != nil { return response } @@ -68,7 +68,7 @@ func (h *RuleValidationHandler) OnUpdate( } } -func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { +func (h *RuleValidationHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { if tnt == nil { return nil } @@ -77,7 +77,9 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. return nil } - for i, rule := range tnt.Spec.Rules { + var bodies []*rules.NamespaceRuleBodyNamespace + + for _, rule := range tnt.Spec.Rules { if rule == nil { continue } @@ -87,39 +89,11 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. continue } - if rule.Enforce == nil { - continue - } - - if rule.NamespaceSelector != nil { - if _, err := metav1.LabelSelectorAsSelector(rule.NamespaceSelector); err != nil { - return ad.Denyf("rules[%d].namespaceSelector is invalid: %v", i, err) - } - } - - for j, registry := range rule.Enforce.Workloads.Registries { - if _, err := regexp.Compile(registry.Expression); err != nil { - return ad.Denyf( - "rules[%d].enforce.workloads.registries[%d].exp %q is invalid: %v", - i, - j, - registry.Expression, - err, - ) - } - } + bodies = append(bodies, body) + } - for j, scheduler := range rule.Enforce.Workloads.Schedulers { - if _, err := regexp.Compile(scheduler.Expression); err != nil { - return ad.Denyf( - "rules[%d].enforce.workloads.schedulers[%d].exp %q is invalid: %v", - i, - j, - scheduler.Expression, - err, - ) - } - } + if err := ruleengine.ValidateRuleStatusBody(bodies); err != nil { + return ad.Deny(err.Error()) } return nil diff --git a/pkg/api/forbidden_list.go b/pkg/api/forbidden_list.go index 03aa0cbe9..e77379f29 100644 --- a/pkg/api/forbidden_list.go +++ b/pkg/api/forbidden_list.go @@ -7,7 +7,7 @@ import ( "fmt" "reflect" "regexp" - "sort" + "slices" "strings" ) @@ -17,18 +17,8 @@ type ForbiddenListSpec struct { Regex string `json:"deniedRegex,omitempty"` } -func (in ForbiddenListSpec) ExactMatch(value string) (ok bool) { - if len(in.Exact) > 0 { - sort.SliceStable(in.Exact, func(i, j int) bool { - return strings.ToLower(in.Exact[i]) < strings.ToLower(in.Exact[j]) - }) - - i := sort.SearchStrings(in.Exact, value) - - ok = i < len(in.Exact) && in.Exact[i] == value - } - - return ok +func (in ForbiddenListSpec) ExactMatch(value string) bool { + return slices.Contains(in.Exact, value) } func (in ForbiddenListSpec) RegexMatch(value string) (ok bool) { diff --git a/pkg/api/forbidden_list_test.go b/pkg/api/forbidden_list_test.go index 3a8ec4f71..bae245829 100644 --- a/pkg/api/forbidden_list_test.go +++ b/pkg/api/forbidden_list_test.go @@ -11,6 +11,16 @@ import ( "github.com/projectcapsule/capsule/pkg/api" ) +func denied() api.ForbiddenListSpec { + return api.ForbiddenListSpec{ + Exact: []string{ + "kubernetes.io/metadata.name", + "pod-security.kubernetes.io/enforce", + "NetworkPolicy", + }, + } +} + func TestForbiddenListSpec_ExactMatch(t *testing.T) { type tc struct { In []string @@ -120,3 +130,38 @@ func TestValidateForbidden(t *testing.T) { } } } + +func TestForbiddenKeysBypassed(t *testing.T) { + for _, k := range []string{"NetworkPolicy", "kubernetes.io/metadata.name"} { + if err := api.ValidateForbidden(map[string]string{k: "owned"}, denied()); err == nil { + t.Errorf("BYPASS CONFIRMED: ValidateForbidden ALLOWED denied key %q (list=%v)", k, denied().Exact) + } else { + t.Logf("(no bypass) correctly denied %q: %v", k, err) + } + } +} + +// Positive control: a third denied key in the SAME list is still correctly +// blocked — proving the policy genuinely forbids these keys and the harness is +// wired right (i.e. the bypass above is selective, not a dead enforcement path). +func TestPositiveControl_StillBlocked(t *testing.T) { + if err := api.ValidateForbidden(map[string]string{"pod-security.kubernetes.io/enforce": "privileged"}, denied()); err == nil { + t.Errorf("control failure: denied key 'pod-security.kubernetes.io/enforce' was NOT blocked") + } +} + +// Negative control: a key the admin did NOT deny is correctly allowed, +// proving the webhook is not simply denying everything. +func TestPoC_NegativeControl_BenignAllowed(t *testing.T) { + if err := api.ValidateForbidden(map[string]string{"app.kubernetes.io/name": "frontend"}, denied()); err != nil { + t.Errorf("control failure: benign key was wrongly denied: %v", err) + } +} + +// Direct primitive check, minimal repro of the root cause. +func TestExactMatch_RootCause(t *testing.T) { + spec := api.ForbiddenListSpec{Exact: []string{"B", "a"}} // mixed case + if !spec.ExactMatch("B") { + t.Errorf("ROOT CAUSE: ExactMatch(%q) returned false though %q is in %v", "B", "B", spec.Exact) + } +} diff --git a/pkg/api/rules/enforce_services_types.go b/pkg/api/rules/enforce_services_types.go new file mode 100644 index 000000000..1f047b4cf --- /dev/null +++ b/pkg/api/rules/enforce_services_types.go @@ -0,0 +1,78 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import "github.com/projectcapsule/capsule/pkg/api" + +// +kubebuilder:object:generate=true +type NamespaceRuleEnforceServicesBody struct { + // Types defines the Service types matched by this rule. + // + // Supported values: + // - ClusterIP + // - NodePort + // - LoadBalancer + // - ExternalName + // + // +optional + // +kubebuilder:validation:items:Enum=ClusterIP;NodePort;LoadBalancer;ExternalName + Types []ServiceType `json:"types,omitempty"` + + // LoadBalancers defines additional constraints for Services of type LoadBalancer. + // +optional + LoadBalancers *ServiceLoadBalancerRule `json:"loadBalancers,omitempty"` + + // ExternalNames defines additional constraints for Services of type ExternalName. + // +optional + ExternalNames *ServiceExternalNameRule `json:"externalNames,omitempty"` + + // NodePorts defines additional constraints for nodePort values. + // +optional + NodePorts *ServiceNodePortRule `json:"nodePorts,omitempty"` +} + +// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer;ExternalName +type ServiceType string + +const ( + ServiceTypeClusterIP ServiceType = "ClusterIP" + ServiceTypeNodePort ServiceType = "NodePort" + ServiceTypeLoadBalancer ServiceType = "LoadBalancer" + ServiceTypeExternalName ServiceType = "ExternalName" +) + +// +kubebuilder:object:generate=true +type ServiceLoadBalancerRule struct { + // CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + // Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + // +optional + CIDRs []string `json:"cidrs,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceExternalNameRule struct { + // Hostnames restricts spec.externalName. + // Empty means no additional hostname restriction once ExternalName is allowed by types. + // +optional + Hostnames []api.ExpressionMatch `json:"hostnames,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceNodePortRule struct { + // Ports restricts explicitly requested nodePort values. + // Empty means no additional port restriction once NodePort is allowed by types. + // +optional + Ports []ServiceNodePortRange `json:"ports,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceNodePortRange struct { + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + From int32 `json:"from"` + + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + To int32 `json:"to"` +} diff --git a/pkg/api/rules/enforce_types.go b/pkg/api/rules/enforce_types.go index 341d65211..584c4c9c1 100644 --- a/pkg/api/rules/enforce_types.go +++ b/pkg/api/rules/enforce_types.go @@ -14,4 +14,8 @@ type NamespaceRuleEnforceBody struct { // Enforcement for Workloads (Pods) Workloads NamespaceRuleEnforceWorkloadsBody `json:"workloads,omitempty"` + + // Enforcement for Services. + // +optional + Services NamespaceRuleEnforceServicesBody `json:"services,omitempty"` } diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go index 0f1e1d976..68c23daa9 100644 --- a/pkg/api/rules/zz_generated.deepcopy.go +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -63,6 +63,7 @@ func (in *NamespaceRuleBodyTenant) DeepCopy() *NamespaceRuleBodyTenant { func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) { *out = *in in.Workloads.DeepCopyInto(&out.Workloads) + in.Services.DeepCopyInto(&out.Services) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. @@ -75,6 +76,41 @@ func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnforceServicesBody) { + *out = *in + if in.Types != nil { + in, out := &in.Types, &out.Types + *out = make([]ServiceType, len(*in)) + copy(*out, *in) + } + if in.LoadBalancers != nil { + in, out := &in.LoadBalancers, &out.LoadBalancers + *out = new(ServiceLoadBalancerRule) + (*in).DeepCopyInto(*out) + } + if in.ExternalNames != nil { + in, out := &in.ExternalNames, &out.ExternalNames + *out = new(ServiceExternalNameRule) + (*in).DeepCopyInto(*out) + } + if in.NodePorts != nil { + in, out := &in.NodePorts, &out.NodePorts + *out = new(ServiceNodePortRule) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceServicesBody. +func (in *NamespaceRuleEnforceServicesBody) DeepCopy() *NamespaceRuleEnforceServicesBody { + if in == nil { + return nil + } + out := new(NamespaceRuleEnforceServicesBody) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnforceWorkloadsBody) { *out = *in @@ -185,3 +221,80 @@ func (in *OCIRegistry) DeepCopy() *OCIRegistry { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) { + *out = *in + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]api.ExpressionMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExternalNameRule. +func (in *ServiceExternalNameRule) DeepCopy() *ServiceExternalNameRule { + if in == nil { + return nil + } + out := new(ServiceExternalNameRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceLoadBalancerRule) DeepCopyInto(out *ServiceLoadBalancerRule) { + *out = *in + if in.CIDRs != nil { + in, out := &in.CIDRs, &out.CIDRs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceLoadBalancerRule. +func (in *ServiceLoadBalancerRule) DeepCopy() *ServiceLoadBalancerRule { + if in == nil { + return nil + } + out := new(ServiceLoadBalancerRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceNodePortRange) DeepCopyInto(out *ServiceNodePortRange) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceNodePortRange. +func (in *ServiceNodePortRange) DeepCopy() *ServiceNodePortRange { + if in == nil { + return nil + } + out := new(ServiceNodePortRange) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceNodePortRule) DeepCopyInto(out *ServiceNodePortRule) { + *out = *in + if in.Ports != nil { + in, out := &in.Ports, &out.Ports + *out = make([]ServiceNodePortRange, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceNodePortRule. +func (in *ServiceNodePortRule) DeepCopy() *ServiceNodePortRule { + if in == nil { + return nil + } + out := new(ServiceNodePortRule) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/ruleengine/convert_test.go b/pkg/ruleengine/convert_test.go new file mode 100644 index 000000000..b21c58ef4 --- /dev/null +++ b/pkg/ruleengine/convert_test.go @@ -0,0 +1,194 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "testing" + + api "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestEnforceBodiesFromNamespaceRules(t *testing.T) { + tests := []struct { + name string + input []*api.NamespaceRuleBodyNamespace + assert func(t *testing.T, got []*api.NamespaceRuleEnforceBody) + }{ + { + name: "nil input returns nil", + input: nil, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } + }, + }, + { + name: "empty input returns nil", + input: []*api.NamespaceRuleBodyNamespace{}, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } + }, + }, + { + name: "only nil bodies returns empty slice", + input: []*api.NamespaceRuleBodyNamespace{ + nil, + nil, + }, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got == nil { + t.Fatalf("expected non-nil empty slice, got nil") + } + + if len(got) != 0 { + t.Fatalf("expected empty slice, got len=%d", len(got)) + } + }, + }, + { + name: "bodies without enforce are skipped", + input: []*api.NamespaceRuleBodyNamespace{ + {}, + { + Enforce: nil, + }, + }, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got == nil { + t.Fatalf("expected non-nil empty slice, got nil") + } + + if len(got) != 0 { + t.Fatalf("expected empty slice, got len=%d", len(got)) + } + }, + }, + { + name: "returns enforce bodies in original order", + input: func() []*api.NamespaceRuleBodyNamespace { + first := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAllow, + } + second := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeDeny, + } + third := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAudit, + } + + return []*api.NamespaceRuleBodyNamespace{ + { + Enforce: first, + }, + nil, + { + Enforce: second, + }, + {}, + { + Enforce: third, + }, + } + }(), + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if len(got) != 3 { + t.Fatalf("expected 3 enforce bodies, got %d", len(got)) + } + + if got[0].Action != api.ActionTypeAllow { + t.Fatalf("expected first action %q, got %q", api.ActionTypeAllow, got[0].Action) + } + + if got[1].Action != api.ActionTypeDeny { + t.Fatalf("expected second action %q, got %q", api.ActionTypeDeny, got[1].Action) + } + + if got[2].Action != api.ActionTypeAudit { + t.Fatalf("expected third action %q, got %q", api.ActionTypeAudit, got[2].Action) + } + }, + }, + { + name: "returns original enforce pointers without deep copy", + input: func() []*api.NamespaceRuleBodyNamespace { + enforce := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAllow, + } + + return []*api.NamespaceRuleBodyNamespace{ + { + Enforce: enforce, + }, + } + }(), + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if len(got) != 1 { + t.Fatalf("expected one enforce body, got %d", len(got)) + } + + if got[0].Action != api.ActionTypeAllow { + t.Fatalf("expected action %q, got %q", api.ActionTypeAllow, got[0].Action) + } + + got[0].Action = api.ActionTypeDeny + + if got[0].Action != api.ActionTypeDeny { + t.Fatalf("expected returned pointer to be mutable") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EnforceBodiesFromNamespaceRules(tt.input) + tt.assert(t, got) + }) + } +} + +func TestEnforceBodiesFromNamespaceRulesReturnsOriginalPointers(t *testing.T) { + first := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAllow, + } + second := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeDeny, + } + + got := EnforceBodiesFromNamespaceRules([]*api.NamespaceRuleBodyNamespace{ + { + Enforce: first, + }, + { + Enforce: second, + }, + }) + + if len(got) != 2 { + t.Fatalf("expected 2 enforce bodies, got %d", len(got)) + } + + if got[0] != first { + t.Fatalf("expected first returned enforce body to be the original pointer") + } + + if got[1] != second { + t.Fatalf("expected second returned enforce body to be the original pointer") + } +} diff --git a/pkg/ruleengine/enforce_evaluator.go b/pkg/ruleengine/enforce_evaluator.go index 8fcad7b0d..7692d06e9 100644 --- a/pkg/ruleengine/enforce_evaluator.go +++ b/pkg/ruleengine/enforce_evaluator.go @@ -5,6 +5,7 @@ package ruleengine import ( "fmt" + "strings" api "github.com/projectcapsule/capsule/pkg/api/rules" ) @@ -17,15 +18,27 @@ type Value struct { type Match struct { Matched bool MatchedValue any + + // Detail is optional human-readable matcher context. + // Example: "10.0.171.239 is contained in 10.0.0.0/16". + Detail string } type Decision struct { - SetName string - EventReason string - Action api.ActionType - Value Value + SetName string + EventReason string + Action api.ActionType + Value Value + MatchedValue any - Message string + + // MatchedRule is the human-readable rule description returned by Set.RuleDescription. + MatchedRule string + + // MatchDetail is the human-readable detail returned by Match.Detail. + MatchDetail string + + Message string } type DecisionError struct { @@ -77,18 +90,25 @@ func (e *Evaluation) Append(other *Evaluation) { } } -type Set[R any, T any] struct { - Name string - +type Set[R any, O any] struct { + Name string EventReason string - Values func(T) []Value + Values func(O) []Value + Rules func(*api.NamespaceRuleEnforceBody) []R + Matches func(R, Value) (Match, error) - Rules func(*api.NamespaceRuleEnforceBody) []R + // Message can fully override the default message. + // Prefer leaving this nil unless a rule requires very specific wording. + Message func(api.ActionType, Value, any) string - Matches func(R, Value) (Match, error) + // RuleDescription returns a human-readable representation of one rule. + // It is used only for admission/audit messages. + RuleDescription func(R) string - Message func(action api.ActionType, value Value, matchedValue any) string + // AllowedDescription optionally overrides the "Allowed values" label. + // Example: "Allowed CIDRs", "Allowed ranges", "Allowed hostnames". + AllowedDescription string } func EvaluateEnforce[R any, T any]( @@ -125,6 +145,7 @@ func EvaluateEnforce[R any, T any]( } hasAllowRule := false + allowRules := make([]R, 0) var lastDecision *Decision @@ -144,6 +165,8 @@ func EvaluateEnforce[R any, T any]( case api.ActionTypeAllow: hasAllowRule = true + allowRules = append(allowRules, items...) + case api.ActionTypeDeny, api.ActionTypeAudit: // Supported actions. @@ -165,13 +188,24 @@ func EvaluateEnforce[R any, T any]( continue } + matchedRule := describeRule(set, item) + decision := &Decision{ SetName: set.Name, EventReason: set.EventReason, Action: action, Value: value, MatchedValue: match.MatchedValue, - Message: decisionMessage(set, action, value, match.MatchedValue), + MatchedRule: matchedRule, + MatchDetail: strings.TrimSpace(match.Detail), + Message: decisionMessage( + set, + action, + value, + match.MatchedValue, + matchedRule, + match.Detail, + ), } switch action { @@ -205,12 +239,7 @@ func EvaluateEnforce[R any, T any]( EventReason: set.EventReason, Action: api.ActionTypeDeny, Value: value, - Message: fmt.Sprintf( - "%s %q at %s is not allowed by namespace rule", - set.Name, - value.Value, - value.Path, - ), + Message: allowMissMessage(set, value, allowRules), } return evaluation, nil @@ -220,41 +249,123 @@ func EvaluateEnforce[R any, T any]( return evaluation, nil } +const maxRuleDescriptions = 10 + +func describeRule[R any, O any](set Set[R, O], rule R) string { + if set.RuleDescription == nil { + return "" + } + + return strings.TrimSpace(set.RuleDescription(rule)) +} + +func describeRules[R any, O any](set Set[R, O], rules []R) string { + if len(rules) == 0 || set.RuleDescription == nil { + return "" + } + + limit := min(len(rules), maxRuleDescriptions) + + parts := make([]string, 0, limit) + + for i := range limit { + description := describeRule(set, rules[i]) + if description == "" { + continue + } + + parts = append(parts, description) + } + + if len(parts) == 0 { + return "" + } + + if len(rules) > maxRuleDescriptions { + parts = append(parts, fmt.Sprintf("and %d more", len(rules)-maxRuleDescriptions)) + } + + return strings.Join(parts, ", ") +} + +func allowedLabel[R any, O any](set Set[R, O]) string { + if set.AllowedDescription != "" { + return set.AllowedDescription + } + + return "Allowed values" +} + +func allowMissMessage[R any, T any]( + set Set[R, T], + value Value, + allowRules []R, +) string { + message := fmt.Sprintf( + "%s %q at %s is not allowed by namespace rule", + set.Name, + value.Value, + value.Path, + ) + + descriptions := describeRules(set, allowRules) + if descriptions == "" { + return message + } + + return fmt.Sprintf( + "%s: value did not match any allowed rule. %s: %s", + message, + allowedLabel(set), + descriptions, + ) +} + func decisionMessage[R any, T any]( set Set[R, T], action api.ActionType, value Value, matchedValue any, + matchedRule string, + matchDetail string, ) string { if set.Message != nil { return set.Message(action, value, matchedValue) } + matchDetail = strings.TrimSpace(matchDetail) + switch action { case api.ActionTypeAudit: - return fmt.Sprintf( + message := fmt.Sprintf( "%s %q at %s matched audit namespace rule", set.Name, value.Value, value.Path, ) + return appendMatchContext(message, matchedRule, matchDetail, "matched audit rule") + case api.ActionTypeDeny: - return fmt.Sprintf( + message := fmt.Sprintf( "%s %q at %s is denied by namespace rule", set.Name, value.Value, value.Path, ) + return appendMatchContext(message, matchedRule, matchDetail, "matched denied rule") + case api.ActionTypeAllow: - return fmt.Sprintf( + message := fmt.Sprintf( "%s %q at %s is allowed by namespace rule", set.Name, value.Value, value.Path, ) + return appendMatchContext(message, matchedRule, matchDetail, "matched allowed rule") + default: return fmt.Sprintf( "%s %q at %s matched namespace rule action %q", @@ -265,3 +376,20 @@ func decisionMessage[R any, T any]( ) } } + +func appendMatchContext( + message string, + matchedRule string, + matchDetail string, + rulePrefix string, +) string { + if matchDetail != "" { + return fmt.Sprintf("%s: %s", message, matchDetail) + } + + if matchedRule != "" { + return fmt.Sprintf("%s: %s %s", message, rulePrefix, matchedRule) + } + + return message +} diff --git a/pkg/ruleengine/enforce_evaluator_test.go b/pkg/ruleengine/enforce_evaluator_test.go index ed7a1ba56..17eb6b400 100644 --- a/pkg/ruleengine/enforce_evaluator_test.go +++ b/pkg/ruleengine/enforce_evaluator_test.go @@ -16,10 +16,11 @@ type testObject struct { } type testRule struct { - Name string - ShouldMatch bool - MatchValue any - Err error + Name string + ShouldMatch bool + MatchedValue any + Detail string + Err error } type enforceSpec struct { @@ -35,8 +36,7 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { t.Parallel() validFixture := newTestFixture() - - validSet := validFixture.set("test", nil) + validSet := validFixture.set("registry", nil) tests := []struct { name string @@ -55,29 +55,29 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { { name: "nil values extractor", set: Set[testRule, testObject]{ - Name: "test", + Name: "registry", Rules: validSet.Rules, Matches: validSet.Matches, }, - wantErr: "test: values extractor is nil", + wantErr: "registry: values extractor is nil", }, { name: "nil rules extractor", set: Set[testRule, testObject]{ - Name: "test", + Name: "registry", Values: validSet.Values, Matches: validSet.Matches, }, - wantErr: "test: rules extractor is nil", + wantErr: "registry: rules extractor is nil", }, { name: "nil matcher", set: Set[testRule, testObject]{ - Name: "test", + Name: "registry", Values: validSet.Values, Rules: validSet.Rules, }, - wantErr: "test: matcher is nil", + wantErr: "registry: matcher is nil", }, } @@ -88,21 +88,21 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { t.Parallel() evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "a", Path: "spec.value"}}}, + testObject{Values: []Value{{Value: "harbor/app:1", Path: "spec.containers[0].image"}}}, []*api.NamespaceRuleEnforceBody{{Action: api.ActionTypeAllow}}, tt.set, ) if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") + t.Fatalf("expected error, got nil") } if evaluation != nil { - t.Fatalf("EvaluateEnforce() evaluation = %#v, want nil on validation error", evaluation) + t.Fatalf("expected nil evaluation on setup validation error, got %#v", evaluation) } if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("EvaluateEnforce() error = %q, want containing %q", err.Error(), tt.wantErr) + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } }) } @@ -123,47 +123,60 @@ func TestEvaluateEnforce_EmptyInputs(t *testing.T) { enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "deny", ShouldMatch: true}, + }, }, }, }, { - name: "only empty value", + name: "empty value is skipped", obj: testObject{ - Values: []Value{{Value: "", Path: "spec.value"}}, + Values: []Value{ + {Value: "", Path: "spec.value"}, + }, }, enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "deny", ShouldMatch: true}, + }, }, }, }, { name: "nil enforce bodies", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, - enforceSpecs: nil, }, { name: "empty enforce bodies", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, enforceSpecs: []enforceSpec{}, }, { name: "nil enforce body is ignored", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, includeNilBody: true, }, { name: "enforce body without rule items is ignored", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, enforceSpecs: []enforceSpec{ { @@ -174,12 +187,16 @@ func TestEvaluateEnforce_EmptyInputs(t *testing.T) { { name: "non matching rule item is ignored", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "deny", ShouldMatch: false}, + }, }, }, }, @@ -200,14 +217,14 @@ func TestEvaluateEnforce_EmptyInputs(t *testing.T) { evaluation, err := EvaluateEnforce(tt.obj, enforceBodies, fixture.set("registry", nil)) if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + t.Fatalf("unexpected error: %v", err) } assertNoBlocking(t, evaluation) assertNoFinal(t, evaluation) if len(evaluation.Audits) != 0 { - t.Fatalf("audits = %d, want 0", len(evaluation.Audits)) + t.Fatalf("expected no audits, got %d", len(evaluation.Audits)) } }) } @@ -219,157 +236,100 @@ func TestEvaluateEnforce_LastMatchingAllowDenyWins(t *testing.T) { tests := []struct { name string enforceSpecs []enforceSpec - wantBlocking bool wantFinalAction api.ActionType + wantBlocking bool + wantMessage string }{ { name: "single allow allows", enforceSpecs: []enforceSpec{ { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, }, wantFinalAction: api.ActionTypeAllow, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is allowed by namespace rule: matched allowed rule harbor/.*`, }, { name: "single deny denies", enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/blocked/.*", ShouldMatch: true}, + }, }, }, - wantBlocking: true, wantFinalAction: api.ActionTypeDeny, + wantBlocking: true, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is denied by namespace rule: matched denied rule harbor/blocked/.*`, }, { name: "deny then allow allows", enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/app:.*", ShouldMatch: true}, + }, }, }, wantFinalAction: api.ActionTypeAllow, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is allowed by namespace rule: matched allowed rule harbor/app:.*`, }, { name: "allow then deny denies", enforceSpecs: []enforceSpec{ { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - }, - wantBlocking: true, - wantFinalAction: api.ActionTypeDeny, - }, - { - name: "unmatched later deny does not override previous allow", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, - }, - wantFinalAction: api.ActionTypeAllow, - }, - { - name: "unmatched later allow does not override previous deny", - enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, + items: []testRule{ + {Name: "harbor/app:.*", ShouldMatch: true}, + }, }, }, - wantBlocking: true, wantFinalAction: api.ActionTypeDeny, - }, - { - name: "multiple matching decisive rules last allow wins", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow-1", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow-2", ShouldMatch: true}}, - }, - }, - wantFinalAction: api.ActionTypeAllow, - }, - { - name: "multiple matching decisive rules last deny wins", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny-1", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny-2", ShouldMatch: true}}, - }, - }, wantBlocking: true, - wantFinalAction: api.ActionTypeDeny, - }, - { - name: "only unmatched allow implicitly denies", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, - }, - }, - wantBlocking: true, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is denied by namespace rule: matched denied rule harbor/app:.*`, }, { - name: "only unmatched deny allows", + name: "last matching rule wins while non matching later rules are ignored", enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "first-deny", ShouldMatch: true}, + }, }, - }, - }, - { - name: "unrelated allow before unrelated deny implicitly denies", - enforceSpecs: []enforceSpec{ { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, + items: []testRule{ + {Name: "allow", ShouldMatch: true}, + }, }, { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "later-non-matching-deny", ShouldMatch: false}, + }, }, }, - wantBlocking: true, + wantFinalAction: api.ActionTypeAllow, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is allowed by namespace rule: matched allowed rule allow`, }, } @@ -380,1029 +340,489 @@ func TestEvaluateEnforce_LastMatchingAllowDenyWins(t *testing.T) { t.Parallel() fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, + }, buildEnforceBodies(fixture, tt.enforceSpecs), fixture.set("registry", nil), ) if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + t.Fatalf("unexpected error: %v", err) } - if tt.wantBlocking { - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } - - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } + assertFinalAction(t, evaluation, tt.wantFinalAction) - if err := evaluation.BlockingError(); err == nil { - t.Fatalf("BlockingError() = nil, want error") - } + if tt.wantBlocking { + assertBlockingAction(t, evaluation, api.ActionTypeDeny) } else { assertNoBlocking(t, evaluation) } - if tt.wantFinalAction == "" { - assertNoFinal(t, evaluation) - - return - } - - if evaluation.Final == nil { - t.Fatalf("Final = nil, want action %q", tt.wantFinalAction) - } - - if evaluation.Final.Action != tt.wantFinalAction { - t.Fatalf("Final.Action = %q, want %q", evaluation.Final.Action, tt.wantFinalAction) - } - - if tt.wantBlocking && evaluation.Blocking != evaluation.Final { - t.Fatalf("Blocking and Final should point to same deny decision") + if evaluation.Final.Message != tt.wantMessage { + t.Fatalf("final message = %q, want %q", evaluation.Final.Message, tt.wantMessage) } }) } } -func TestEvaluateEnforce_AuditSemantics(t *testing.T) { +func TestEvaluateEnforce_DefaultActionIsDeny(t *testing.T) { t.Parallel() - tests := []struct { - name string - enforceSpecs []enforceSpec - wantAudits int - wantBlocking bool - wantFinal api.ActionType - }{ - { - name: "single audit allows and records audit", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - }, - { - name: "multiple audits are all recorded", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit-1", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit-2", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit-3", ShouldMatch: true}}, - }, - }, - wantAudits: 3, - }, - { - name: "unmatched audit is ignored", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: false}}, - }, - }, - wantAudits: 0, - }, - { - name: "audit plus deny records audit and denies", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, - { - name: "deny plus audit records audit but final deny remains", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, - { - name: "audit plus allow records audit and allows", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantFinal: api.ActionTypeAllow, - }, - { - name: "allow plus audit records audit and final allow remains", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantFinal: api.ActionTypeAllow, - }, - { - name: "audit records audit but does not prevent implicit allow-list deny", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - }, - { - name: "audit plus allow plus later deny denies and records audit", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, - { - name: "audit plus deny plus later allow allows and records audit", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantFinal: api.ActionTypeAllow, - }, - { - name: "unmatched audit after matching allow does not change final allow", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: false}}, - }, + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - wantAudits: 0, - wantFinal: api.ActionTypeAllow, }, - { - name: "unmatched audit after matching deny does not change final deny", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: false}}, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: "", + items: []testRule{ + {Name: "default-deny", ShouldMatch: true}, }, }, - wantAudits: 0, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - fixture := newTestFixture() + if evaluation.Blocking.Message != `registry "harbor/app:1" at spec.containers[0].image is denied by namespace rule: matched denied rule default-deny` { + t.Fatalf("blocking message = %q", evaluation.Blocking.Message) + } +} - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - buildEnforceBodies(fixture, tt.enforceSpecs), - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } +func TestEvaluateEnforce_AllowListMissDenies(t *testing.T) { + t.Parallel() - if len(evaluation.Audits) != tt.wantAudits { - t.Fatalf("audits = %d, want %d", len(evaluation.Audits), tt.wantAudits) - } + fixture := newTestFixture() - for _, audit := range evaluation.Audits { - if audit.Action != api.ActionTypeAudit { - t.Fatalf("audit action = %q, want %q", audit.Action, api.ActionTypeAudit) - } + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "docker.io/library/nginx:latest", Path: "spec.containers[0].image"}, + }, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: false}, + {Name: "registry.local/.*", ShouldMatch: false}, + }, + }, + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if audit.Message == "" { - t.Fatalf("audit message is empty") - } - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) + assertNoFinal(t, evaluation) - if tt.wantBlocking { - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + want := `registry "docker.io/library/nginx:latest" at spec.containers[0].image is not allowed by namespace rule: value did not match any allowed rule. Allowed registries: harbor/.*, registry.local/.*` + if evaluation.Blocking.Message != want { + t.Fatalf("blocking message = %q, want %q", evaluation.Blocking.Message, want) + } +} - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } +func TestEvaluateEnforce_AllowMissWithoutRuleDescriptionsUsesBaseMessage(t *testing.T) { + t.Parallel() - if err := evaluation.BlockingError(); err == nil { - t.Fatalf("BlockingError() = nil, want error") - } - } else { - assertNoBlocking(t, evaluation) - } + fixture := newTestFixture() - if tt.wantFinal == "" { - assertNoFinal(t, evaluation) + set := fixture.set("registry", nil) + set.RuleDescription = nil + set.AllowedDescription = "" - return - } + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "docker.io/library/nginx:latest", Path: "spec.containers[0].image"}, + }, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: false}, + }, + }, + }), + set, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if evaluation.Final == nil { - t.Fatalf("Final = nil, want %q", tt.wantFinal) - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - if evaluation.Final.Action != tt.wantFinal { - t.Fatalf("Final.Action = %q, want %q", evaluation.Final.Action, tt.wantFinal) - } - }) + want := `registry "docker.io/library/nginx:latest" at spec.containers[0].image is not allowed by namespace rule` + if evaluation.Blocking.Message != want { + t.Fatalf("blocking message = %q, want %q", evaluation.Blocking.Message, want) } } -func TestEvaluateEnforce_ListValues(t *testing.T) { +func TestEvaluateEnforce_AllowMissDescriptionsAreLimited(t *testing.T) { t.Parallel() - t.Run("blocks on first value not matched by allow-list", func(t *testing.T) { - t.Parallel() + fixture := newTestFixture() + + items := make([]testRule, 0, 12) + for i := 0; i < 12; i++ { + items = append(items, testRule{ + Name: "rule-" + string(rune('a'+i)), + ShouldMatch: false, + }) + } - set := Set[string, testObject]{ - Name: "registry", - EventReason: "ForbiddenRegistry", - Values: func(obj testObject) []Value { - return obj.Values + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "unmatched", Path: "spec.value"}, }, - Rules: func(_ *api.NamespaceRuleEnforceBody) []string { - return []string{"allowed"} - }, - Matches: func(rule string, value Value) (Match, error) { - return Match{Matched: value.Value == rule}, nil - }, - } - - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "blocked", Path: "containers[0]"}, - {Value: "allowed", Path: "containers[1]"}, - }, - }, - []*api.NamespaceRuleEnforceBody{ - {Action: api.ActionTypeAllow}, - }, - set, - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAllow, + items: items, + }, + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want implicit allow-list deny") - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - if evaluation.Blocking.Value.Value != "blocked" { - t.Fatalf("Blocking.Value.Value = %q, want blocked", evaluation.Blocking.Value.Value) - } + msg := evaluation.Blocking.Message - if evaluation.Blocking.Value.Path != "containers[0]" { - t.Fatalf("Blocking.Value.Path = %q, want containers[0]", evaluation.Blocking.Value.Path) + for _, expected := range []string{ + "rule-a", + "rule-j", + "and 2 more", + } { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) } + } - if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil because first value was implicitly denied", evaluation.Final) + for _, unexpected := range []string{ + "rule-k", + "rule-l", + } { + if strings.Contains(msg, unexpected) { + t.Fatalf("expected message %q not to contain %q", msg, unexpected) } - }) - + } } -func TestEvaluateEnforce_AllowListImplicitDeny(t *testing.T) { +func TestEvaluateEnforce_AuditIsObservational(t *testing.T) { t.Parallel() - t.Run("unmatched allow creates blocking decision without final decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() + fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAllow, testRule{ - Name: "allow", - ShouldMatch: false, - }), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "audit/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want implicit allow-list deny") - } - - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } - - if evaluation.Blocking.Value.Value != "harbor/app:1" { - t.Fatalf("Blocking.Value.Value = %q, want harbor/app:1", evaluation.Blocking.Value.Value) - } - - if evaluation.Blocking.Value.Path != "containers[0]" { - t.Fatalf("Blocking.Value.Path = %q, want containers[0]", evaluation.Blocking.Value.Path) - } - - if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil for implicit allow-list deny", evaluation.Final) - } - - if !strings.Contains(evaluation.Blocking.Message, "is not allowed by namespace rule") { - t.Fatalf("Blocking.Message = %q, want implicit allow-list deny message", evaluation.Blocking.Message) - } - }) - - t.Run("audit-only rules remain observational and do not deny", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{ - Name: "audit", - ShouldMatch: true, - }), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{ + { + Name: "audit/.*", + ShouldMatch: true, + Detail: `"audit/app:1" matched registry rule audit/.*`, + }, + }, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } + assertNoBlocking(t, evaluation) + assertNoFinal(t, evaluation) - assertNoBlocking(t, evaluation) - assertNoFinal(t, evaluation) - }) + if len(evaluation.Audits) != 1 { + t.Fatalf("expected 1 audit, got %d", len(evaluation.Audits)) + } - t.Run("matched audit does not prevent implicit allow-list deny", func(t *testing.T) { - t.Parallel() + audit := evaluation.Audits[0] - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{ - Name: "audit", - ShouldMatch: true, - }), - fixture.enforce(api.ActionTypeAllow, testRule{ - Name: "allow", - ShouldMatch: false, - }), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } + if audit.Action != api.ActionTypeAudit { + t.Fatalf("audit action = %q, want %q", audit.Action, api.ActionTypeAudit) + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want implicit allow-list deny") - } + if audit.MatchedRule != "audit/.*" { + t.Fatalf("audit matched rule = %q, want %q", audit.MatchedRule, "audit/.*") + } - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } + if audit.MatchDetail != `"audit/app:1" matched registry rule audit/.*` { + t.Fatalf("audit match detail = %q", audit.MatchDetail) + } - if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil for implicit allow-list deny", evaluation.Final) - } - }) + want := `registry "audit/app:1" at spec.containers[0].image matched audit namespace rule: "audit/app:1" matched registry rule audit/.*` + if audit.Message != want { + t.Fatalf("audit message = %q, want %q", audit.Message, want) + } } -func TestEvaluateEnforce_MultipleValues(t *testing.T) { +func TestEvaluateEnforce_AuditDoesNotSatisfyAllowList(t *testing.T) { t.Parallel() - t.Run("continues after allowed first value and blocks on second value", func(t *testing.T) { - t.Parallel() + fixture := newTestFixture() - set := Set[string, testObject]{ - Name: "registry", - EventReason: "ForbiddenRegistry", - Values: func(obj testObject) []Value { - return obj.Values - }, - Rules: func(_ *api.NamespaceRuleEnforceBody) []string { - return []string{"bad"} + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "audit/app:1", Path: "spec.containers[0].image"}, }, - Matches: func(rule string, value Value) (Match, error) { - return Match{Matched: value.Value == rule}, nil - }, - } - - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "good", Path: "containers[0]"}, - {Value: "bad", Path: "containers[1]"}, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{ + {Name: "audit/.*", ShouldMatch: true}, }, }, - []*api.NamespaceRuleEnforceBody{ - {Action: api.ActionTypeDeny}, + { + action: api.ActionTypeAllow, + items: []testRule{ + {Name: "allowed/.*", ShouldMatch: false}, + }, }, - set, - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + if len(evaluation.Audits) != 1 { + t.Fatalf("expected 1 audit, got %d", len(evaluation.Audits)) + } - if evaluation.Blocking.Value.Value != "bad" { - t.Fatalf("Blocking.Value.Value = %q, want %q", evaluation.Blocking.Value.Value, "bad") - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) + assertNoFinal(t, evaluation) - if evaluation.Blocking.Value.Path != "containers[1]" { - t.Fatalf("Blocking.Value.Path = %q, want %q", evaluation.Blocking.Value.Path, "containers[1]") - } - }) + if !strings.Contains(evaluation.Blocking.Message, "Allowed registries: allowed/.*") { + t.Fatalf("blocking message = %q", evaluation.Blocking.Message) + } +} - t.Run("skips empty values and evaluates non-empty values", func(t *testing.T) { - t.Parallel() +func TestEvaluateEnforce_MatchDetailOverridesMatchedRuleInMessage(t *testing.T) { + t.Parallel() - fixture := newTestFixture() + fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "", Path: "containers[0]"}, - {Value: "bad", Path: "containers[1]"}, - }, - }, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeDeny, testRule{Name: "deny", ShouldMatch: true}), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "10.0.171.239", Path: "spec.loadBalancerIP"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } - - if evaluation.Blocking.Value.Path != "containers[1]" { - t.Fatalf("Blocking.Value.Path = %q, want containers[1]", evaluation.Blocking.Value.Path) - } - }) - - t.Run("audits all matching values", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "a", Path: "containers[0]"}, - {Value: "b", Path: "containers[1]"}, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{ + { + Name: "10.0.0.0/8", + ShouldMatch: true, + MatchedValue: "10.0.0.0/8", + Detail: "10.0.171.239 is contained in 10.0.0.0/8", + }, }, }, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if len(evaluation.Audits) != 2 { - t.Fatalf("audits = %d, want 2", len(evaluation.Audits)) - } - - if evaluation.Audits[0].Value.Path != "containers[0]" { - t.Fatalf("first audit path = %q, want containers[0]", evaluation.Audits[0].Value.Path) - } - - if evaluation.Audits[1].Value.Path != "containers[1]" { - t.Fatalf("second audit path = %q, want containers[1]", evaluation.Audits[1].Value.Path) - } - }) + }), + fixture.set("loadBalancer CIDR", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - t.Run("stops after first blocking value", func(t *testing.T) { - t.Parallel() + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - set := Set[string, testObject]{ - Name: "registry", - Values: func(obj testObject) []Value { - return obj.Values - }, - Rules: func(_ *api.NamespaceRuleEnforceBody) []string { - return []string{"bad", "worse"} - }, - Matches: func(rule string, value Value) (Match, error) { - return Match{Matched: value.Value == rule}, nil - }, - } + blocking := evaluation.Blocking - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "bad", Path: "containers[0]"}, - {Value: "worse", Path: "containers[1]"}, - }, - }, - []*api.NamespaceRuleEnforceBody{ - {Action: api.ActionTypeDeny}, - }, - set, - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + if blocking.MatchedRule != "10.0.0.0/8" { + t.Fatalf("matched rule = %q, want %q", blocking.MatchedRule, "10.0.0.0/8") + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + if blocking.MatchDetail != "10.0.171.239 is contained in 10.0.0.0/8" { + t.Fatalf("match detail = %q", blocking.MatchDetail) + } - if evaluation.Blocking.Value.Value != "bad" { - t.Fatalf("Blocking.Value.Value = %q, want bad", evaluation.Blocking.Value.Value) - } - }) + want := `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is denied by namespace rule: 10.0.171.239 is contained in 10.0.0.0/8` + if blocking.Message != want { + t.Fatalf("blocking message = %q, want %q", blocking.Message, want) + } } -func TestEvaluateEnforce_MatchedValueAndMessages(t *testing.T) { +func TestEvaluateEnforce_CustomMessageOverridesDefaultMessage(t *testing.T) { t.Parallel() - t.Run("matched value is propagated to final decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - matchedValue := map[string]string{"rule": "compiled-registry-rule"} - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAllow, testRule{ - Name: "allow", - ShouldMatch: true, - MatchValue: matchedValue, - }), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Final == nil { - t.Fatalf("Final = nil, want decision") - } - - got, ok := evaluation.Final.MatchedValue.(map[string]string) - if !ok { - t.Fatalf("Final.MatchedValue type = %T, want map[string]string", evaluation.Final.MatchedValue) - } - - if got["rule"] != "compiled-registry-rule" { - t.Fatalf("Final.MatchedValue[rule] = %q", got["rule"]) - } - }) - - t.Run("matched value is propagated to blocking decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - matchedValue := "compiled-deny-rule" - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeDeny, testRule{ - Name: "deny", - ShouldMatch: true, - MatchValue: matchedValue, - }), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + fixture := newTestFixture() - if evaluation.Blocking.MatchedValue != matchedValue { - t.Fatalf("Blocking.MatchedValue = %#v, want %#v", evaluation.Blocking.MatchedValue, matchedValue) - } + set := fixture.set("registry", func(action api.ActionType, value Value, matched any) string { + return "custom message" }) - t.Run("matched value is propagated to audit decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - matchedValue := "compiled-audit-rule" - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{ - Name: "audit", - ShouldMatch: true, - MatchValue: matchedValue, - }), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } - - if evaluation.Audits[0].MatchedValue != matchedValue { - t.Fatalf("audit MatchedValue = %#v, want %#v", evaluation.Audits[0].MatchedValue, matchedValue) - } - }) - - t.Run("custom message is used", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeDeny, testRule{Name: "deny", ShouldMatch: true}), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, - fixture.set("registry", func(action api.ActionType, value Value, matchedValue any) string { - return "custom: " + string(action) + " " + value.Value - }), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } - - if evaluation.Blocking.Message != "custom: deny app:1" { - t.Fatalf("Blocking.Message = %q, want custom message", evaluation.Blocking.Message) - } - }) - - t.Run("default messages are populated", func(t *testing.T) { - t.Parallel() - - for _, action := range []api.ActionType{ - api.ActionTypeAllow, - api.ActionTypeDeny, - api.ActionTypeAudit, - } { - action := action - - t.Run(string(action), func(t *testing.T) { - t.Parallel() + }), + set, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - fixture := newTestFixture() + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(action, testRule{Name: string(action), ShouldMatch: true}), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - switch action { - case api.ActionTypeAllow: - if evaluation.Final == nil || evaluation.Final.Message == "" { - t.Fatalf("allow final message is empty") - } - case api.ActionTypeDeny: - if evaluation.Blocking == nil || evaluation.Blocking.Message == "" { - t.Fatalf("deny blocking message is empty") - } - case api.ActionTypeAudit: - if len(evaluation.Audits) != 1 || evaluation.Audits[0].Message == "" { - t.Fatalf("audit message is empty") - } - } - }) - } - }) + if evaluation.Blocking.Message != "custom message" { + t.Fatalf("blocking message = %q, want custom message", evaluation.Blocking.Message) + } } -func TestEvaluateEnforce_DefaultActionAndUnsupportedAction(t *testing.T) { +func TestEvaluateEnforce_MatcherError(t *testing.T) { t.Parallel() - t.Run("empty action defaults to deny", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() + fixture := newTestFixture() + matchErr := errors.New("invalid regex") - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce("", testRule{Name: "default-deny", ShouldMatch: true}), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want default deny decision") - } - - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } - }) - - t.Run("unsupported action returns error with partial evaluation", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), - fixture.enforce(api.ActionType("unsupported"), testRule{Name: "bad", ShouldMatch: true}), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{ + { + Name: "bad-rule", + ShouldMatch: true, + Err: matchErr, + }, + }, }, - fixture.set("registry", nil), - ) - if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") - } - - if !strings.Contains(err.Error(), `registry: unsupported rule action "unsupported"`) { - t.Fatalf("error = %q, want unsupported action message", err.Error()) - } + }), + fixture.set("registry", nil), + ) + if err == nil { + t.Fatalf("expected error, got nil") + } - if evaluation == nil { - t.Fatalf("evaluation = nil, want partial evaluation") - } + if evaluation == nil { + t.Fatalf("expected non-nil evaluation after runtime matcher error") + } - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1 from partial evaluation", len(evaluation.Audits)) - } - }) + if !strings.Contains(err.Error(), "registry: invalid rule: invalid regex") { + t.Fatalf("error = %q", err.Error()) + } } -func TestEvaluateEnforce_MatcherErrors(t *testing.T) { +func TestEvaluateEnforce_UnsupportedAction(t *testing.T) { t.Parallel() - t.Run("matcher error is wrapped and returns partial evaluation", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - wantErr := errors.New("matcher failed") + fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAllow, testRule{Name: "allow", ShouldMatch: true, Err: wantErr}), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - - if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") - } - - if !errors.Is(err, wantErr) { - t.Fatalf("EvaluateEnforce() error = %v, want wrapping %v", err, wantErr) - } - - if !strings.Contains(err.Error(), "registry: invalid rule") { - t.Fatalf("EvaluateEnforce() error = %q, want invalid rule context", err.Error()) - } - - if evaluation == nil { - t.Fatalf("evaluation = nil, want partial evaluation") - } - }) - - t.Run("matcher error after audit keeps audit in partial evaluation", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - wantErr := errors.New("matcher failed") - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), - fixture.enforce(api.ActionTypeAllow, testRule{Name: "allow", Err: wantErr}), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionType("invalid"), + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, - fixture.set("registry", nil), - ) - - if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") - } - - if !errors.Is(err, wantErr) { - t.Fatalf("EvaluateEnforce() error = %v, want wrapping %v", err, wantErr) - } + }), + fixture.set("registry", nil), + ) + if err == nil { + t.Fatalf("expected error, got nil") + } - if evaluation == nil { - t.Fatalf("evaluation = nil, want partial evaluation") - } + if evaluation == nil { + t.Fatalf("expected non-nil evaluation after runtime action error") + } - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } - }) + if !strings.Contains(err.Error(), `registry: unsupported rule action "invalid"`) { + t.Fatalf("error = %q", err.Error()) + } } func TestEvaluation_BlockingError(t *testing.T) { t.Parallel() - t.Run("nil evaluation has no blocking error", func(t *testing.T) { - t.Parallel() - - var evaluation *Evaluation - - if err := evaluation.BlockingError(); err != nil { - t.Fatalf("BlockingError() = %v, want nil", err) - } - }) - - t.Run("evaluation without blocking has no blocking error", func(t *testing.T) { - t.Parallel() - - evaluation := &Evaluation{} - - if err := evaluation.BlockingError(); err != nil { - t.Fatalf("BlockingError() = %v, want nil", err) - } - }) - - t.Run("evaluation with blocking returns decision error", func(t *testing.T) { - t.Parallel() - - decision := &Decision{ - SetName: "registry", - Action: api.ActionTypeDeny, - Message: "denied", - } - - evaluation := &Evaluation{ - Blocking: decision, - } + if err := (*Evaluation)(nil).BlockingError(); err != nil { + t.Fatalf("nil evaluation BlockingError() = %v, want nil", err) + } - err := evaluation.BlockingError() - if err == nil { - t.Fatalf("BlockingError() = nil, want error") - } + evaluation := &Evaluation{} + if err := evaluation.BlockingError(); err != nil { + t.Fatalf("empty evaluation BlockingError() = %v, want nil", err) + } - var decisionErr *DecisionError - if !errors.As(err, &decisionErr) { - t.Fatalf("BlockingError() type = %T, want *DecisionError", err) - } + evaluation.Blocking = &Decision{ + Message: "blocked by rule", + } - if decisionErr.Decision != decision { - t.Fatalf("DecisionError.Decision = %#v, want original decision", decisionErr.Decision) - } + err := evaluation.BlockingError() + if err == nil { + t.Fatalf("expected blocking error, got nil") + } - if err.Error() != "denied" { - t.Fatalf("BlockingError().Error() = %q, want denied", err.Error()) - } - }) + if err.Error() != "blocked by rule" { + t.Fatalf("blocking error = %q, want %q", err.Error(), "blocked by rule") + } } -func TestDecisionError_Error(t *testing.T) { +func TestDecisionError_ErrorFallback(t *testing.T) { t.Parallel() tests := []struct { name string err *DecisionError - want string }{ { - name: "nil error receiver", + name: "nil error", err: nil, - want: "namespace rule decision denied request", }, { name: "nil decision", err: &DecisionError{}, - want: "namespace rule decision denied request", - }, - { - name: "decision message", - err: &DecisionError{ - Decision: &Decision{ - Message: "custom denied message", - }, - }, - want: "custom denied message", - }, - { - name: "empty decision message", - err: &DecisionError{ - Decision: &Decision{}, - }, - want: "", }, } @@ -1412,9 +832,8 @@ func TestDecisionError_Error(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := tt.err.Error() - if got != tt.want { - t.Fatalf("Error() = %q, want %q", got, tt.want) + if tt.err.Error() != "namespace rule decision denied request" { + t.Fatalf("Error() = %q", tt.err.Error()) } }) } @@ -1423,193 +842,148 @@ func TestDecisionError_Error(t *testing.T) { func TestEvaluation_Append(t *testing.T) { t.Parallel() - t.Run("nil receiver is no-op", func(t *testing.T) { + t.Run("nil receiver or nil other does nothing", func(t *testing.T) { t.Parallel() var evaluation *Evaluation + evaluation.Append(&Evaluation{}) - evaluation.Append(&Evaluation{ - Audits: []*Decision{{SetName: "audit"}}, - Final: &Decision{Action: api.ActionTypeAllow}, - }) - }) + nonNil := &Evaluation{} + nonNil.Append(nil) - t.Run("nil other is no-op", func(t *testing.T) { - t.Parallel() + assertNoBlocking(t, nonNil) + assertNoFinal(t, nonNil) - evaluation := &Evaluation{ - Audits: []*Decision{{SetName: "audit-1"}}, - Final: &Decision{Action: api.ActionTypeAllow}, - } - - evaluation.Append(nil) - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } - - if evaluation.Final == nil || evaluation.Final.Action != api.ActionTypeAllow { - t.Fatalf("Final = %#v, want allow", evaluation.Final) + if len(nonNil.Audits) != 0 { + t.Fatalf("expected no audits, got %d", len(nonNil.Audits)) } }) - t.Run("appends audits and replaces final and blocking", func(t *testing.T) { + t.Run("appends audits and overrides final and blocking", func(t *testing.T) { t.Parallel() - initialFinal := &Decision{SetName: "initial", Action: api.ActionTypeAllow} - newFinal := &Decision{SetName: "new", Action: api.ActionTypeDeny} - newBlocking := &Decision{SetName: "new", Action: api.ActionTypeDeny} + firstAudit := &Decision{Action: api.ActionTypeAudit, Message: "audit-1"} + secondAudit := &Decision{Action: api.ActionTypeAudit, Message: "audit-2"} + final := &Decision{Action: api.ActionTypeAllow, Message: "final"} + blocking := &Decision{Action: api.ActionTypeDeny, Message: "blocking"} evaluation := &Evaluation{ - Audits: []*Decision{{SetName: "audit-1"}}, - Final: initialFinal, + Audits: []*Decision{ + firstAudit, + }, } evaluation.Append(&Evaluation{ - Audits: []*Decision{{SetName: "audit-2"}, {SetName: "audit-3"}}, - Final: newFinal, - Blocking: newBlocking, + Audits: []*Decision{ + secondAudit, + }, + Final: final, + Blocking: blocking, }) - if len(evaluation.Audits) != 3 { - t.Fatalf("audits = %d, want 3", len(evaluation.Audits)) - } - - if evaluation.Final != newFinal { - t.Fatalf("Final = %#v, want new final", evaluation.Final) + if len(evaluation.Audits) != 2 { + t.Fatalf("audits = %d, want 2", len(evaluation.Audits)) } - if evaluation.Blocking != newBlocking { - t.Fatalf("Blocking = %#v, want new blocking", evaluation.Blocking) + if evaluation.Audits[0] != firstAudit { + t.Fatalf("first audit pointer was not preserved") } - }) - - t.Run("does not replace final or blocking with nil", func(t *testing.T) { - t.Parallel() - - final := &Decision{SetName: "final", Action: api.ActionTypeAllow} - blocking := &Decision{SetName: "blocking", Action: api.ActionTypeDeny} - evaluation := &Evaluation{ - Final: final, - Blocking: blocking, + if evaluation.Audits[1] != secondAudit { + t.Fatalf("second audit pointer was not appended") } - evaluation.Append(&Evaluation{ - Audits: []*Decision{{SetName: "audit"}}, - }) - if evaluation.Final != final { - t.Fatalf("Final was replaced unexpectedly") + t.Fatalf("final was not updated") } if evaluation.Blocking != blocking { - t.Fatalf("Blocking was replaced unexpectedly") - } - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + t.Fatalf("blocking was not updated") } }) } -func TestDecisionMessage(t *testing.T) { +func TestMessageHelpers(t *testing.T) { t.Parallel() set := Set[testRule, testObject]{ - Name: "registry", + Name: "registry", + AllowedDescription: "Allowed registries", + RuleDescription: func(rule testRule) string { + return rule.Name + }, } - value := Value{ - Value: "harbor/app:1", - Path: "containers[0]", - } + t.Run("allowedLabel default", func(t *testing.T) { + t.Parallel() - tests := []struct { - name string - action api.ActionType - want string - }{ - { - name: "audit", - action: api.ActionTypeAudit, - want: `registry "harbor/app:1" at containers[0] matched audit namespace rule`, - }, - { - name: "deny", - action: api.ActionTypeDeny, - want: `registry "harbor/app:1" at containers[0] is denied by namespace rule`, - }, - { - name: "allow", - action: api.ActionTypeAllow, - want: `registry "harbor/app:1" at containers[0] is allowed by namespace rule`, - }, - { - name: "unknown", - action: api.ActionType("custom"), - want: `registry "harbor/app:1" at containers[0] matched namespace rule action "custom"`, - }, - } + defaultSet := Set[testRule, testObject]{} - for _, tt := range tests { - tt := tt + if got := allowedLabel(defaultSet); got != "Allowed values" { + t.Fatalf("allowedLabel() = %q, want %q", got, "Allowed values") + } + }) - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + t.Run("appendMatchContext uses detail before rule", func(t *testing.T) { + t.Parallel() - got := decisionMessage(set, tt.action, value, nil) - if got != tt.want { - t.Fatalf("decisionMessage() = %q, want %q", got, tt.want) - } - }) - } -} + got := appendMatchContext("message", "rule", "detail", "matched rule") + if got != "message: detail" { + t.Fatalf("appendMatchContext() = %q, want %q", got, "message: detail") + } + }) -func TestDecisionMessage_CustomMessageReceivesMatchedValue(t *testing.T) { - t.Parallel() + t.Run("appendMatchContext uses rule when detail is empty", func(t *testing.T) { + t.Parallel() - set := Set[testRule, testObject]{ - Name: "registry", - Message: func(action api.ActionType, value Value, matchedValue any) string { - return string(action) + ":" + value.Value + ":" + matchedValue.(string) - }, - } + got := appendMatchContext("message", "rule", "", "matched rule") + if got != "message: matched rule rule" { + t.Fatalf("appendMatchContext() = %q", got) + } + }) - value := Value{ - Value: "harbor/app:1", - Path: "containers[0]", - } + t.Run("appendMatchContext leaves message unchanged without context", func(t *testing.T) { + t.Parallel() - got := decisionMessage(set, api.ActionTypeAudit, value, "compiled-rule") - want := "audit:harbor/app:1:compiled-rule" + got := appendMatchContext("message", "", "", "matched rule") + if got != "message" { + t.Fatalf("appendMatchContext() = %q, want message", got) + } + }) - if got != want { - t.Fatalf("decisionMessage() = %q, want %q", got, want) - } + t.Run("describeRules skips empty descriptions", func(t *testing.T) { + t.Parallel() + + got := describeRules(set, []testRule{ + {Name: "first"}, + {Name: ""}, + {Name: "second"}, + }) + + if got != "first, second" { + t.Fatalf("describeRules() = %q, want %q", got, "first, second") + } + }) } func newTestFixture() *testFixture { return &testFixture{ - items: make(map[*api.NamespaceRuleEnforceBody][]testRule), + items: map[*api.NamespaceRuleEnforceBody][]testRule{}, } } func (f *testFixture) set( name string, - message func(action api.ActionType, value Value, matchedValue any) string, + message func(api.ActionType, Value, any) string, ) Set[testRule, testObject] { return Set[testRule, testObject]{ - Name: name, - EventReason: "TestReason", + Name: name, + EventReason: "NamespaceRuleViolation", + AllowedDescription: "Allowed registries", Values: func(obj testObject) []Value { return obj.Values }, Rules: func(enforce *api.NamespaceRuleEnforceBody) []testRule { - if enforce == nil { - return nil - } - return f.items[enforce] }, Matches: func(rule testRule, value Value) (Match, error) { @@ -1617,54 +991,91 @@ func (f *testFixture) set( return Match{}, rule.Err } + if !rule.ShouldMatch { + return Match{}, nil + } + + matchedValue := rule.MatchedValue + if matchedValue == nil { + matchedValue = rule.Name + } + return Match{ - Matched: rule.ShouldMatch, - MatchedValue: rule.MatchValue, + Matched: true, + MatchedValue: matchedValue, + Detail: rule.Detail, }, nil }, Message: message, + RuleDescription: func(rule testRule) string { + return rule.Name + }, } } -func (f *testFixture) enforce( - action api.ActionType, - rules ...testRule, -) *api.NamespaceRuleEnforceBody { - body := &api.NamespaceRuleEnforceBody{ - Action: action, - } - - f.items[body] = rules - - return body -} - func buildEnforceBodies( fixture *testFixture, specs []enforceSpec, ) []*api.NamespaceRuleEnforceBody { + if len(specs) == 0 { + return nil + } + out := make([]*api.NamespaceRuleEnforceBody, 0, len(specs)) for _, spec := range specs { - out = append(out, fixture.enforce(spec.action, spec.items...)) + enforce := &api.NamespaceRuleEnforceBody{ + Action: spec.action, + } + + fixture.items[enforce] = spec.items + out = append(out, enforce) } return out } +func assertBlockingAction(t *testing.T, evaluation *Evaluation, action api.ActionType) { + t.Helper() + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if evaluation.Blocking.Action != action { + t.Fatalf("blocking action = %q, want %q", evaluation.Blocking.Action, action) + } +} + func assertNoBlocking(t *testing.T, evaluation *Evaluation) { t.Helper() if evaluation == nil { - t.Fatalf("evaluation = nil, want non-nil") + t.Fatalf("expected evaluation, got nil") } if evaluation.Blocking != nil { - t.Fatalf("Blocking = %#v, want nil", evaluation.Blocking) + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) } +} - if err := evaluation.BlockingError(); err != nil { - t.Fatalf("BlockingError() = %v, want nil", err) +func assertFinalAction(t *testing.T, evaluation *Evaluation, action api.ActionType) { + t.Helper() + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if evaluation.Final.Action != action { + t.Fatalf("final action = %q, want %q", evaluation.Final.Action, action) } } @@ -1672,10 +1083,10 @@ func assertNoFinal(t *testing.T, evaluation *Evaluation) { t.Helper() if evaluation == nil { - t.Fatalf("evaluation = nil, want non-nil") + t.Fatalf("expected evaluation, got nil") } if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil", evaluation.Final) + t.Fatalf("expected no final decision, got %#v", evaluation.Final) } } diff --git a/pkg/ruleengine/validate.go b/pkg/ruleengine/validate.go new file mode 100644 index 000000000..58909363e --- /dev/null +++ b/pkg/ruleengine/validate.go @@ -0,0 +1,183 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package ruleengine + +import ( + "fmt" + "net" + "regexp" + "strings" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error { + for i, rule := range bodies { + if rule == nil || rule.Enforce == nil { + continue + } + + if err := validateWorkloadRules(i, rule.Enforce.Workloads); err != nil { + return err + } + + if err := validateServiceRules(i, rule.Enforce.Services); err != nil { + return err + } + } + + return nil +} + +func validateWorkloadRules( + ruleIndex int, + workloads rules.NamespaceRuleEnforceWorkloadsBody, +) error { + for j, registry := range workloads.Registries { + if err := validateExpression( + registry.Expression, + fmt.Sprintf("rules[%d].enforce.workloads.registries[%d].exp", ruleIndex, j), + ); err != nil { + return err + } + } + + for j, scheduler := range workloads.Schedulers { + if err := validateExpression( + scheduler.Expression, + fmt.Sprintf("rules[%d].enforce.workloads.schedulers[%d].exp", ruleIndex, j), + ); err != nil { + return err + } + } + + return nil +} + +func validateServiceRules( + ruleIndex int, + services rules.NamespaceRuleEnforceServicesBody, +) error { + for j, serviceType := range services.Types { + if err := validateServiceType(serviceType); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.types[%d] %q is invalid: %w", + ruleIndex, + j, + serviceType, + err, + ) + } + } + + if services.LoadBalancers != nil { + for j, cidr := range services.LoadBalancers.CIDRs { + if err := validateCIDR(cidr); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.loadBalancers.cidrs[%d] %q is invalid: %w", + ruleIndex, + j, + cidr, + err, + ) + } + } + } + + if services.ExternalNames != nil { + for j, hostname := range services.ExternalNames.Hostnames { + if err := validateExpressionMatch( + hostname, + fmt.Sprintf("rules[%d].enforce.services.externalNames.hostnames[%d]", ruleIndex, j), + ); err != nil { + return err + } + } + } + + if services.NodePorts != nil { + for j, portRange := range services.NodePorts.Ports { + if err := validateNodePortRange(portRange); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.nodePorts.ports[%d] is invalid: %w", + ruleIndex, + j, + err, + ) + } + } + } + + return nil +} + +func validateExpressionMatch(match api.ExpressionMatch, fieldPath string) error { + if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil { + return err + } + + return nil +} + +func validateExpression(expression string, fieldPath string) error { + if strings.TrimSpace(expression) == "" { + return nil + } + + if _, err := regexp.Compile(expression); err != nil { + return fmt.Errorf("%s %q is invalid: %w", fieldPath, expression, err) + } + + return nil +} + +func validateServiceType(serviceType rules.ServiceType) error { + switch serviceType { + case rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName: + return nil + default: + return fmt.Errorf("unsupported service type") + } +} + +func validateCIDR(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("CIDR is empty") + } + + if !strings.Contains(raw, "/") { + ip := net.ParseIP(raw) + if ip == nil { + return fmt.Errorf("must be a valid IP or CIDR") + } + + return nil + } + + if _, _, err := net.ParseCIDR(raw); err != nil { + return err + } + + return nil +} + +func validateNodePortRange(portRange rules.ServiceNodePortRange) error { + if portRange.From < 1 || portRange.From > 65535 { + return fmt.Errorf("from %d must be between 1 and 65535", portRange.From) + } + + if portRange.To < 1 || portRange.To > 65535 { + return fmt.Errorf("to %d must be between 1 and 65535", portRange.To) + } + + if portRange.From > portRange.To { + return fmt.Errorf("from %d must be lower than or equal to %d", portRange.From, portRange.To) + } + + return nil +} diff --git a/pkg/ruleengine/validate_test.go b/pkg/ruleengine/validate_test.go new file mode 100644 index 000000000..1e3ac5fc7 --- /dev/null +++ b/pkg/ruleengine/validate_test.go @@ -0,0 +1,360 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package ruleengine + +import ( + "strings" + "testing" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestValidateRuleStatusBody(t *testing.T) { + tests := []struct { + name string + bodies []*rules.NamespaceRuleBodyNamespace + wantErr string + }{ + { + name: "nil bodies are valid", + bodies: []*rules.NamespaceRuleBodyNamespace{ + nil, + {}, + { + Enforce: nil, + }, + }, + }, + { + name: "valid workload and service rules", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "harbor/.*", + }, + }, + }, + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "harbor/platform/debian:latest", + }, + }, + }, + }, + Schedulers: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "tenant-[a-z0-9-]+", + }, + }, + }, + }, + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName, + }, + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: []string{ + "10.0.0.2/32", + "10.0.1.0/24", + "2001:db8::/32", + "10.0.0.3", + }, + }, + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: []api.ExpressionMatch{ + { + Exact: []string{ + "internal.git.com", + }, + }, + { + ExpressionRegex: api.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }, + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted\\..*", + Negate: true, + }, + }, + }, + }, + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 30000, + To: 32767, + }, + { + From: 30500, + To: 30500, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "invalid workload registry regex", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.workloads.registries[0].exp "[" is invalid`, + }, + { + name: "invalid workload scheduler regex", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.workloads.schedulers[0].exp "[" is invalid`, + }, + { + name: "invalid service type", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceType("InvalidType"), + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.types[1] "InvalidType" is invalid`, + }, + { + name: "invalid loadBalancer CIDR", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: []string{ + "10.0.0.0/33", + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "10.0.0.0/33" is invalid`, + }, + { + name: "empty loadBalancer CIDR", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: []string{ + "", + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "" is invalid: CIDR is empty`, + }, + { + name: "invalid externalName hostname regex", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.externalNames.hostnames[0].exp "[" is invalid`, + }, + { + name: "nodePort from greater than to", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 32767, + To: 30000, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 32767 must be lower than or equal to to 30000`, + }, + { + name: "nodePort from below valid port range", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 0, + To: 30000, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 0 must be between 1 and 65535`, + }, + { + name: "nodePort to above valid port range", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 30000, + To: 70000, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: to 70000 must be between 1 and 65535`, + }, + { + name: "single nodePort range is valid", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 30500, + To: 30500, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "reports correct indexes across multiple rules", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + }, + }, + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "valid\\..*", + }, + }, + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[1].enforce.services.externalNames.hostnames[1].exp "[" is invalid`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRuleStatusBody(tt.bodies) + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index 79fe344e0..d6c61d638 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -16,7 +16,6 @@ const ( // RuleStatus. ReasonNamespaceRuleAudit string = "NamespaceRuleAudit" - // Namespace. ReasonNamespaceHijack string = "ReasonNamespacePatch" @@ -59,6 +58,8 @@ const ( ReasonForbiddenLoadBalancer string = "ForbiddenLoadBalancer" ReasonForbiddenExternalName string = "ForbiddenExternalName" ReasonForbiddenNodePort string = "ForbiddenNodePort" + ReasonForbiddenServiceType string = "ForbiddenServiceType" + ReasonForbiddenLoadBalancerCIDR string = "ForbiddenLoadBalancerCIDR" // Storage. ReasonCrossTenantReference string = "CrossTenantReference" diff --git a/svc.yaml b/svc.yaml new file mode 100644 index 000000000..0b36310f6 --- /dev/null +++ b/svc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + selector: + app.kubernetes.io/name: MyApp + ports: + - protocol: TCP + port: 80 + targetPort: 9376 + loadBalancerIP: 10.0.171.239 + type: LoadBalancer From 586b8d1891a4f6f1509306031a9acbf7edf55bad Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Wed, 24 Jun 2026 06:49:42 +0200 Subject: [PATCH 07/10] feat(rules): add service enforcement rules Signed-off-by: Oliver Baehler --- svc.yaml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 svc.yaml diff --git a/svc.yaml b/svc.yaml deleted file mode 100644 index 0b36310f6..000000000 --- a/svc.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: my-service -spec: - selector: - app.kubernetes.io/name: MyApp - ports: - - protocol: TCP - port: 80 - targetPort: 9376 - loadBalancerIP: 10.0.171.239 - type: LoadBalancer From 283a88e0ca717ff981cf0a27304211f6431c6811 Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Wed, 24 Jun 2026 09:40:21 +0200 Subject: [PATCH 08/10] feat(rules): add service enforcement rules Signed-off-by: Oliver Baehler --- charts/capsule/README.md | 9 +- charts/capsule/ci/ha-values.yaml | 2 + charts/capsule/templates/flowschema.yaml | 135 ++++++++++++++++++ .../templates/prioritylevelconfiguration.yaml | 13 ++ charts/capsule/values.schema.json | 54 ++++++- charts/capsule/values.yaml | 29 +++- cmd/controller/main.go | 1 + .../services/validation/node_port_test.go | 2 +- pkg/ruleengine/validate_test.go | 2 +- 9 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 charts/capsule/templates/flowschema.yaml create mode 100644 charts/capsule/templates/prioritylevelconfiguration.yaml diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 84b0c9a2c..065d9ca06 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -105,6 +105,10 @@ The following Values have changed key or Value: | Key | Type | Default | Description | |-----|------|---------|-------------| +| manager.apiPriorityAndFairness.enabled | bool | `false` | Change to `true` if you want to insulate the API calls made by Capsule admission controller activities. This will help ensure Capsule stability in busy clusters. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/ | +| manager.apiPriorityAndFairness.flowApiVersion | string | `"flowcontrol.apiserver.k8s.io/v1"` | Declare ApiVersion used for Flow | +| manager.apiPriorityAndFairness.matchingPrecedence | int | `900` | Only the first matching FlowSchema for a given request matters. If multiple FlowSchemas match a single inbound request, it will be assigned based on the one with the highest matchingPrecedence. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#flowschema | +| manager.apiPriorityAndFairness.priorityLevelConfigurationSpec | object | See [values.yaml](values.yaml) | Priority level configuration. The block is directly forwarded into the priorityLevelConfiguration, so you can use whatever specification you want. ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#prioritylevelconfiguration | | manager.daemonsetStrategy | object | `{"type":"RollingUpdate"}` | [Daemonset Strategy](https://kubernetes.io/docs/tasks/manage-daemon/update-daemon-set/#creating-a-daemonset-with-rollingupdate-update-strategy) | | manager.deploymentStrategy | object | `{"type":"RollingUpdate"}` | [Deployment Strategy](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy) | | manager.env | list | `[]` | Additional Environment Variables | @@ -348,14 +352,13 @@ The following Values have changed key or Value: | webhooks.hooks.resourcepools.pools.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.resourcepools.pools.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.resourcepools.pools.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) | -| webhooks.hooks.rulestatus | object | `{"enabled":false,"failurePolicy":"Fail","matchConditions":[],"matchPolicy":"Equivalent","namespaceSelector":{},"objectSelector":{},"rules":[]}` | Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) | -| webhooks.hooks.rulestatus.enabled | bool | `false` | Enable the Hook | +| webhooks.hooks.rulestatus | object | `{"enabled":true,"failurePolicy":"Fail","matchConditions":[],"matchPolicy":"Equivalent","namespaceSelector":{},"objectSelector":{}}` | Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) | +| webhooks.hooks.rulestatus.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.rulestatus.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.rulestatus.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | | webhooks.hooks.rulestatus.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | | webhooks.hooks.rulestatus.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | | webhooks.hooks.rulestatus.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | -| webhooks.hooks.rulestatus.rules | list | `[]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | | webhooks.hooks.serviceaccounts.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.serviceaccounts.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.serviceaccounts.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | diff --git a/charts/capsule/ci/ha-values.yaml b/charts/capsule/ci/ha-values.yaml index abc7300c7..c04d20cfd 100644 --- a/charts/capsule/ci/ha-values.yaml +++ b/charts/capsule/ci/ha-values.yaml @@ -2,3 +2,5 @@ replicaCount: 2 manager: extraArgs: - "--enable-leader-election=true" + apiPriorityAndFairness: + enabled: true diff --git a/charts/capsule/templates/flowschema.yaml b/charts/capsule/templates/flowschema.yaml new file mode 100644 index 000000000..f26e708ca --- /dev/null +++ b/charts/capsule/templates/flowschema.yaml @@ -0,0 +1,135 @@ +{{- if .Values.manager.apiPriorityAndFairness.enabled }} +--- +apiVersion: {{ .Values.manager.apiPriorityAndFairness.flowApiVersion }} +kind: FlowSchema +metadata: + name: {{ include "capsule.fullname" $ }} + labels: + {{- include "capsule.labels" $ | nindent 4 }} +spec: + matchingPrecedence: {{ .Values.manager.apiPriorityAndFairness.matchingPrecedence }} + priorityLevelConfiguration: + name: {{ include "capsule.fullname" $ }} + distinguisherMethod: + type: ByNamespace + rules: + - subjects: + - kind: ServiceAccount + serviceAccount: + namespace: {{ $.Release.Namespace }} + name: {{ include "capsule.serviceAccountName" $ }} + resourceRules: + # Core resources reconciled/read by Capsule. + - apiGroups: + - "" + resources: + - namespaces + - namespaces/status + - pods + - services + - serviceaccounts + - configmaps + - secrets + - resourcequotas + - resourcequotas/status + - limitranges + - persistentvolumeclaims + - events + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + + - apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + - rolebindings + - clusterroles + - clusterrolebindings + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - bind + - escalate + + - apiGroups: + - capsule.clastix.io + resources: + - tenants + - tenants/status + - tenantowners + - tenantowners/status + - rulestatus + - rulestatus/status + - customquotas + - customquotas/status + - globalcustomquotas + - globalcustomquotas/status + - globaltenantresources + - globaltenantresources/status + - tenantresources + - tenantresources/status + - resourcepools + - resourcepools/status + - resourcepoolclaims + - resourcepoolclaims/status + - quantityledgers + - quantityledgers/status + - capsuleconfigurations + - capsuleconfigurations/status + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - admissionregistration.k8s.io + resources: + - mutatingwebhookconfigurations + - validatingwebhookconfigurations + verbs: + - get + - list + - watch + - update + - patch + - create + + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +{{- end }} diff --git a/charts/capsule/templates/prioritylevelconfiguration.yaml b/charts/capsule/templates/prioritylevelconfiguration.yaml new file mode 100644 index 000000000..33234ccb1 --- /dev/null +++ b/charts/capsule/templates/prioritylevelconfiguration.yaml @@ -0,0 +1,13 @@ +{{- if .Values.manager.apiPriorityAndFairness.enabled }} +--- +apiVersion: {{ .Values.manager.apiPriorityAndFairness.flowApiVersion }} +kind: PriorityLevelConfiguration +metadata: + name: {{ include "capsule.fullname" $ }} + labels: + {{- include "capsule.labels" $ | nindent 4 }} +{{- with .Values.manager.apiPriorityAndFairness.priorityLevelConfigurationSpec }} +spec: + {{- tpl (toYaml .) $ | nindent 2 }} +{{- end }} +{{- end }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index 5f64e34bf..4aec85e95 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -274,6 +274,56 @@ "manager": { "type": "object", "properties": { + "apiPriorityAndFairness": { + "type": "object", + "properties": { + "enabled": { + "description": "Change to `true` if you want to insulate the API calls made by Capsule admission controller activities. This will help ensure Capsule stability in busy clusters. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/", + "type": "boolean" + }, + "flowApiVersion": { + "description": "Declare ApiVersion used for Flow", + "type": "string" + }, + "matchingPrecedence": { + "description": "Only the first matching FlowSchema for a given request matters. If multiple FlowSchemas match a single inbound request, it will be assigned based on the one with the highest matchingPrecedence. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#flowschema", + "type": "integer" + }, + "priorityLevelConfigurationSpec": { + "description": "Priority level configuration. The block is directly forwarded into the priorityLevelConfiguration, so you can use whatever specification you want. ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#prioritylevelconfiguration", + "type": "object", + "properties": { + "limited": { + "type": "object", + "properties": { + "limitResponse": { + "type": "object", + "properties": { + "queuing": { + "type": "object", + "properties": { + "queueLengthLimit": { + "type": "integer" + } + } + }, + "type": { + "type": "string" + } + } + }, + "nominalConcurrencyShares": { + "type": "integer" + } + } + }, + "type": { + "type": "string" + } + } + } + } + }, "daemonsetStrategy": { "description": "[Daemonset Strategy](https://kubernetes.io/docs/tasks/manage-daemon/update-daemon-set/#creating-a-daemonset-with-rollingupdate-update-strategy)", "type": "object", @@ -2096,10 +2146,6 @@ "objectSelector": { "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", "type": "object" - }, - "rules": { - "description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)", - "type": "array" } } }, diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 0f4d52220..dc4375138 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -113,6 +113,31 @@ rbac: # Manager Options manager: + apiPriorityAndFairness: + # -- Change to `true` if you want to insulate the API calls made by Capsule admission controller activities. + # This will help ensure Capsule stability in busy clusters. + # Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/ + enabled: false + + # -- Only the first matching FlowSchema for a given request matters. If multiple FlowSchemas match a single inbound request, it will be assigned based on the one with the highest matchingPrecedence. + # Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#flowschema + matchingPrecedence: 900 + + # -- Declare ApiVersion used for Flow + flowApiVersion: "flowcontrol.apiserver.k8s.io/v1" + + # -- Priority level configuration. + # The block is directly forwarded into the priorityLevelConfiguration, so you can use whatever specification you want. + # ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#prioritylevelconfiguration + # @default -- See [values.yaml](values.yaml) + priorityLevelConfigurationSpec: + type: Limited + limited: + nominalConcurrencyShares: 10 + limitResponse: + queuing: + queueLengthLimit: 50 + type: Queue # Manager RBAC rbac: @@ -587,7 +612,7 @@ webhooks: # -- Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) rulestatus: # -- Enable the Hook - enabled: false + enabled: true # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) failurePolicy: Fail # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) @@ -601,8 +626,6 @@ webhooks: # operator: Exists # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: [] - # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) - rules: [] # -- Additional Metadata webhook metadata: diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 216e59177..2f9184216 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -684,6 +684,7 @@ func main() { cfgvalidation.OwnerHandler(), ), ), + route.RulesValidating(cfg), ) nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion) diff --git a/internal/webhook/rules/services/validation/node_port_test.go b/internal/webhook/rules/services/validation/node_port_test.go index d31dbe4f1..5fd7b703d 100644 --- a/internal/webhook/rules/services/validation/node_port_test.go +++ b/internal/webhook/rules/services/validation/node_port_test.go @@ -294,7 +294,7 @@ func TestServiceRulesValidateNodePorts(t *testing.T) { enforceBodies: []*apirules.NamespaceRuleEnforceBody{ nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30100, 30000)), }, - wantErr: `nodePort: invalid rule: invalid nodePort range: from 30100 must be lower than or equal to to 30000`, + wantErr: `nodePort: invalid rule: invalid nodePort range: from 30100 must be lower than or equal to 30000`, }, { name: "unsupported action returns error", diff --git a/pkg/ruleengine/validate_test.go b/pkg/ruleengine/validate_test.go index 1e3ac5fc7..e39b9a815 100644 --- a/pkg/ruleengine/validate_test.go +++ b/pkg/ruleengine/validate_test.go @@ -238,7 +238,7 @@ func TestValidateRuleStatusBody(t *testing.T) { }, }, }, - wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 32767 must be lower than or equal to to 30000`, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 32767 must be lower than or equal to 30000`, }, { name: "nodePort from below valid port range", From 5ff51c0ac71ad62c4660ef6d9364f3f6f9ade782 Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Wed, 24 Jun 2026 10:02:03 +0200 Subject: [PATCH 09/10] feat(rules): add service enforcement rules Signed-off-by: Oliver Baehler --- charts/capsule/templates/flowschema.yaml | 77 +++++++++++++++++++----- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/charts/capsule/templates/flowschema.yaml b/charts/capsule/templates/flowschema.yaml index f26e708ca..1a53fe12a 100644 --- a/charts/capsule/templates/flowschema.yaml +++ b/charts/capsule/templates/flowschema.yaml @@ -19,12 +19,23 @@ spec: namespace: {{ $.Release.Namespace }} name: {{ include "capsule.serviceAccountName" $ }} resourceRules: - # Core resources reconciled/read by Capsule. - apiGroups: - "" resources: - namespaces - namespaces/status + namespaces: [] + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: - pods - services - serviceaccounts @@ -35,6 +46,8 @@ spec: - limitranges - persistentvolumeclaims - events + namespaces: + - '*' verbs: - get - list @@ -43,14 +56,29 @@ spec: - update - patch - delete - - apiGroups: - rbac.authorization.k8s.io resources: - - roles - - rolebindings - clusterroles - clusterrolebindings + namespaces: [] + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - bind + - escalate + - apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + - rolebindings + namespaces: + - '*' verbs: - get - list @@ -61,30 +89,43 @@ spec: - delete - bind - escalate - - apiGroups: - capsule.clastix.io + namespaces: + - '*' resources: - - tenants - - tenants/status - - tenantowners - - tenantowners/status - rulestatus - rulestatus/status - customquotas - customquotas/status - - globalcustomquotas - - globalcustomquotas/status - - globaltenantresources - - globaltenantresources/status - tenantresources - tenantresources/status - - resourcepools - - resourcepools/status - resourcepoolclaims - resourcepoolclaims/status - quantityledgers - quantityledgers/status + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - capsule.clastix.io + namespaces: [] + resources: + - tenants + - tenants/status + - tenantowners + - tenantowners/status + - globalcustomquotas + - globalcustomquotas/status + - globaltenantresources + - globaltenantresources/status + - resourcepools + - resourcepools/status - capsuleconfigurations - capsuleconfigurations/status verbs: @@ -99,6 +140,8 @@ spec: - networking.k8s.io resources: - networkpolicies + namespaces: + - '*' verbs: - get - list @@ -112,6 +155,7 @@ spec: resources: - mutatingwebhookconfigurations - validatingwebhookconfigurations + namespaces: [] verbs: - get - list @@ -119,9 +163,10 @@ spec: - update - patch - create - - apiGroups: - coordination.k8s.io + namespaces: + - {{ $.Release.Namespace }} resources: - leases verbs: From e556a4ab85f85383a864e40420e275cda28349d3 Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Wed, 24 Jun 2026 10:24:23 +0200 Subject: [PATCH 10/10] feat(rules): add service enforcement rules Signed-off-by: Oliver Baehler --- charts/capsule/templates/flowschema.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/charts/capsule/templates/flowschema.yaml b/charts/capsule/templates/flowschema.yaml index 1a53fe12a..4ff5b160a 100644 --- a/charts/capsule/templates/flowschema.yaml +++ b/charts/capsule/templates/flowschema.yaml @@ -24,6 +24,7 @@ spec: resources: - namespaces - namespaces/status + clusterScope: true namespaces: [] verbs: - get @@ -61,6 +62,7 @@ spec: resources: - clusterroles - clusterrolebindings + clusterScope: true namespaces: [] verbs: - get @@ -70,8 +72,6 @@ spec: - update - patch - delete - - bind - - escalate - apiGroups: - rbac.authorization.k8s.io resources: @@ -87,8 +87,6 @@ spec: - update - patch - delete - - bind - - escalate - apiGroups: - capsule.clastix.io namespaces: @@ -114,6 +112,7 @@ spec: - delete - apiGroups: - capsule.clastix.io + clusterScope: true namespaces: [] resources: - tenants @@ -152,6 +151,7 @@ spec: - delete - apiGroups: - admissionregistration.k8s.io + clusterScope: true resources: - mutatingwebhookconfigurations - validatingwebhookconfigurations