diff --git a/pkg/resources/resources.go b/pkg/resources/resources.go index 3180d9158..d2eedb729 100644 --- a/pkg/resources/resources.go +++ b/pkg/resources/resources.go @@ -19,6 +19,7 @@ package resources import ( "context" "fmt" + "slices" "strings" "time" @@ -80,7 +81,12 @@ func matchesNamedResources(logger klog.Logger, namedResources []kubebindv1alpha2 return false } -func matchesReferences(logger klog.Logger, references []kubebindv1alpha2.SelectorReference, obj *unstructured.Unstructured, potentiallyReferencedResources *unstructured.UnstructuredList) bool { +func matchesReferences(logger klog.Logger, + references []kubebindv1alpha2.SelectorReference, + obj *unstructured.Unstructured, + potentiallyReferencedResources *unstructured.UnstructuredList, + isolation kubebindv1alpha2.Isolation, +) bool { if len(references) == 0 { return false } @@ -131,14 +137,20 @@ func matchesReferences(logger klog.Logger, references []kubebindv1alpha2.Selecto namespaceMatches = false objNamespace := obj.GetNamespace() - for _, extractedNamespace := range namespaceValues { - if extractedNamespace == objNamespace { - namespaceMatches = true - break - } + if slices.Contains(namespaceValues, objNamespace) { + namespaceMatches = true } } + if selector.JSONPath.Namespace == "" && isolation == kubebindv1alpha2.IsolationNamespaced { + namespaceMatches = (refObj.GetNamespace() == obj.GetNamespace()) + logger.V(4).Info("namespace extracted from referenced object", + "referencedObjectNamespace", refObj.GetNamespace(), + "objectNamespace", obj.GetNamespace(), + "namespaceMatches", namespaceMatches, + ) + } + if nameMatches && namespaceMatches { logger.Info("resource matched", "name", obj.GetName(), "namespace", obj.GetNamespace(), "claim-type", "reference", "reference", selector) return true @@ -152,7 +164,7 @@ func matchesReferences(logger klog.Logger, references []kubebindv1alpha2.Selecto // IsClaimed returns true if the given object matches the given selector and named resources. // Logger here is already at V(4) level. -func IsClaimed(logger klog.Logger, selector kubebindv1alpha2.Selector, obj *unstructured.Unstructured, potentiallyReferencedResources *unstructured.UnstructuredList) bool { +func IsClaimed(logger klog.Logger, selector kubebindv1alpha2.Selector, obj *unstructured.Unstructured, potentiallyReferencedResources *unstructured.UnstructuredList, isolation kubebindv1alpha2.Isolation) bool { if obj == nil { return false } @@ -164,7 +176,7 @@ func IsClaimed(logger klog.Logger, selector kubebindv1alpha2.Selector, obj *unst labelSelectorMatches := matchesLabelSelector(logger, selector.LabelSelector, obj) namedResourceMatches := matchesNamedResources(logger, selector.NamedResources, obj) - referenceMatches := matchesReferences(logger, selector.References, obj, potentiallyReferencedResources) + referenceMatches := matchesReferences(logger, selector.References, obj, potentiallyReferencedResources, isolation) return labelSelectorMatches || namedResourceMatches || referenceMatches } @@ -193,6 +205,16 @@ func IsClaimedWithReference( for _, ref := range claim.Selector.References { logger := logger.WithValues("referenceGroup", ref.Group, "referenceResource", ref.Resource, "referenceJSONPathName", ref.JSONPath.Name, "referenceJSONPathNamespace", ref.JSONPath.Namespace) logger.Info("processing reference selector") + + if !isReferenceAllowed(ref, apiServiceExport) { + logger.Info("reference not allowed, resource is not part of the contract", + "isolation", apiServiceExport.Spec.ClusterScopedIsolation, + "referenceGroup", ref.Group, + "referenceResource", ref.Resource, + ) + continue + } + if len(ref.Versions) > 0 { versions = ref.Versions } else { @@ -232,7 +254,7 @@ func IsClaimedWithReference( if consumerSide { logger.Info("checking claim on consumer side") - result := IsClaimed(logger, claim.Selector, copy, potentiallyReferencedResources) + result := IsClaimed(logger, claim.Selector, copy, potentiallyReferencedResources, apiServiceExport.Spec.ClusterScopedIsolation) logger.Info("IsClaimed result", "result", result) return result } @@ -258,7 +280,21 @@ func IsClaimedWithReference( copy.SetNamespace(sn.Name) } - result := IsClaimed(logger, claim.Selector, copy, potentiallyReferencedResources) + result := IsClaimed(logger, claim.Selector, copy, potentiallyReferencedResources, apiServiceExport.Spec.ClusterScopedIsolation) logger.V(4).Info("IsClaimed result (provider side)", "result", result) return result } + +func isReferenceAllowed(ref kubebindv1alpha2.SelectorReference, apiServiceExport *kubebindv1alpha2.APIServiceExport) bool { + // If isolation is None, everything should be allowed, as both ends are owned. + if apiServiceExport.Spec.ClusterScopedIsolation == kubebindv1alpha2.IsolationNone { + return true + } + for _, resource := range apiServiceExport.Spec.Resources { + if ref.Group == resource.Group && ref.Resource == resource.Resource { + return true + } + } + + return false +} diff --git a/pkg/resources/resources_test.go b/pkg/resources/resources_test.go index 985c806ad..bf1685dfe 100644 --- a/pkg/resources/resources_test.go +++ b/pkg/resources/resources_test.go @@ -32,6 +32,7 @@ func TestSelector_IsClaimed(t *testing.T) { selector kubebindv1alpha2.Selector obj *unstructured.Unstructured potentiallyReferencedResources *unstructured.UnstructuredList + isolation kubebindv1alpha2.Isolation want bool }{ { @@ -95,7 +96,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "named resource selector should match exact name and namespace", @@ -116,7 +118,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "named resource selector should match name when namespace is empty", @@ -137,7 +140,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "named resource selector should not match different name", @@ -158,7 +162,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "named resource selector should not match different namespace", @@ -179,7 +184,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "named resource selector should match one of multiple resources", @@ -204,7 +210,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "label selector with object having no labels should not match", @@ -224,7 +231,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "combination of label selector and named resource should match when both match", @@ -253,7 +261,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "combination of label selector and named resource should match when named resource matches (OR logic)", @@ -282,7 +291,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "combination of label selector and named resource should match when label matches (OR logic)", @@ -311,7 +321,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "label-only-secret test case - should sync when has matching label (OR logic)", @@ -348,7 +359,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, // Should match because label selector matches (OR logic) + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, // Should match because label selector matches (OR logic) }, // JSONPath Reference tests { @@ -391,7 +403,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "reference selector should match when JSONPath extracts matching name and namespace", @@ -435,7 +448,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "reference selector should not match when JSONPath extracts different name", @@ -477,7 +491,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "reference selector should not match when JSONPath extracts different namespace", @@ -521,7 +536,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "reference selector should handle array JSONPath results", @@ -565,7 +581,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "reference selector should handle array JSONPath with namespace extraction", @@ -619,7 +636,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: true, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, }, { name: "reference selector should not match with bracket wildcard syntax (unsupported)", @@ -665,7 +683,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, // Should not match because [*] syntax is not supported by gjson + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, // Should not match because [*] syntax is not supported by gjson }, { name: "reference selector should not match when no referenced resources provided", @@ -691,7 +710,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "reference selector should not match when JSONPath does not exist", @@ -731,7 +751,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, }, { name: "kubeconfig secret case - should not match when only has kube-bind.io/owner label but selector requires app=sheriff", @@ -786,7 +807,8 @@ func TestSelector_IsClaimed(t *testing.T) { }, }, }, - want: false, // Should NOT match because it doesn't have app=sheriff label and is not referenced by Sheriff + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, // Should NOT match because it doesn't have app=sheriff label and is not referenced by Sheriff }, { name: "cert-manager secret reference - should match when certificate references secret via secretName", @@ -840,7 +862,8 @@ func TestSelector_IsClaimed(t *testing.T) { "type": "kubernetes.io/tls", }, }, - want: true, // Should match because certificate's .spec.secretName equals secret name and no namespace JSONPath means namespace matching is handled by caller + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, // Should match because certificate's .spec.secretName equals secret name and no namespace JSONPath means namespace matching is handled by caller }, { name: "cert-manager secret reference - should not match when certificate references different secret", @@ -887,7 +910,8 @@ func TestSelector_IsClaimed(t *testing.T) { "type": "kubernetes.io/tls", }, }, - want: false, // Should not match because certificate's .spec.secretName is "other-secret", not "my-tls-cert" + isolation: kubebindv1alpha2.IsolationPrefixed, + want: false, // Should not match because certificate's .spec.secretName is "other-secret", not "my-tls-cert" }, { name: "cert-manager secret reference - cross-namespace scenario with no namespace JSONPath", @@ -934,17 +958,185 @@ func TestSelector_IsClaimed(t *testing.T) { "type": "kubernetes.io/tls", }, }, - want: true, // Should match because when no namespace JSONPath is provided, namespace matching is delegated to caller + isolation: kubebindv1alpha2.IsolationPrefixed, + want: true, // Should match because when no namespace JSONPath is provided, namespace matching is delegated to caller + }, + { + name: "namespace inherited from referencing object when JSONPath not provided in the namespaced isolation mode", + potentiallyReferencedResources: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]any{ + "metadata": map[string]any{ + "name": "mangodb-instance", + "namespace": "default", + }, + "spec": map[string]any{ + "secret": map[string]any{ + "name": "my-secret", + }, + }, + }, + }, + }, + }, + selector: kubebindv1alpha2.Selector{ + References: []kubebindv1alpha2.SelectorReference{ + { + GroupResource: kubebindv1alpha2.GroupResource{ + Group: "mangodb.com", + Resource: "mangodbs", + }, + JSONPath: &kubebindv1alpha2.JSONPath{ + Name: "spec.secret.name", + }, + }, + }, + }, + obj: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "default", + }, + }, + }, + isolation: kubebindv1alpha2.IsolationNamespaced, + want: true, + }, + { + name: "namespace inheritance fails when in different namespace in the namespaced isolation mode", + potentiallyReferencedResources: &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + { + Object: map[string]any{ + "metadata": map[string]any{ + "name": "mangodb-instance", + "namespace": "default", + }, + "spec": map[string]any{ + "secret": map[string]any{ + "name": "my-secret", + }, + }, + }, + }, + }, + }, + selector: kubebindv1alpha2.Selector{ + References: []kubebindv1alpha2.SelectorReference{ + { + GroupResource: kubebindv1alpha2.GroupResource{ + Group: "mangodb.com", + Resource: "mangodbs", + }, + JSONPath: &kubebindv1alpha2.JSONPath{ + Name: "spec.secret.name", + }, + }, + }, + }, + obj: &unstructured.Unstructured{ + Object: map[string]any{ + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "other-namespace", + }, + }, + }, + isolation: kubebindv1alpha2.IsolationNamespaced, + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { logger := klog.Background().V(4) - got := IsClaimed(logger, tt.selector, tt.obj, tt.potentiallyReferencedResources) + got := IsClaimed(logger, tt.selector, tt.obj, tt.potentiallyReferencedResources, tt.isolation) if got != tt.want { t.Errorf("IsClaimed() = %v, want %v", got, tt.want) } }) } } + +func TestReferenceSelector_IsolationMode(t *testing.T) { + tests := []struct { + name string + isolation kubebindv1alpha2.Isolation + exportedResources []kubebindv1alpha2.APIServiceExportResource + reference kubebindv1alpha2.SelectorReference + shouldAllow bool + }{ + { + name: "IsolationNone allows all references", + isolation: kubebindv1alpha2.IsolationNone, + exportedResources: []kubebindv1alpha2.APIServiceExportResource{ + {GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}}, + }, + reference: kubebindv1alpha2.SelectorReference{ + GroupResource: kubebindv1alpha2.GroupResource{Group: "secret.com", Resource: "secrets"}, + }, + shouldAllow: true, + }, + { + name: "IsolationNamespaced blocks non-exported references", + isolation: kubebindv1alpha2.IsolationNamespaced, + exportedResources: []kubebindv1alpha2.APIServiceExportResource{ + {GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}}, + }, + reference: kubebindv1alpha2.SelectorReference{ + GroupResource: kubebindv1alpha2.GroupResource{Group: "secret.com", Resource: "secrets"}, + }, + shouldAllow: false, + }, + { + name: "IsolationPrefixed blocks non-exported references", + isolation: kubebindv1alpha2.IsolationPrefixed, + exportedResources: []kubebindv1alpha2.APIServiceExportResource{ + {GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}}, + }, + reference: kubebindv1alpha2.SelectorReference{ + GroupResource: kubebindv1alpha2.GroupResource{Group: "secret.com", Resource: "secrets"}, + }, + shouldAllow: false, + }, + { + name: "IsolationPrefixed allows exported references", + isolation: kubebindv1alpha2.IsolationPrefixed, + exportedResources: []kubebindv1alpha2.APIServiceExportResource{ + {GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}}, + }, + reference: kubebindv1alpha2.SelectorReference{ + GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}, + }, + shouldAllow: true, + }, + { + name: "IsolationNamespaced allows exported references", + isolation: kubebindv1alpha2.IsolationNamespaced, + exportedResources: []kubebindv1alpha2.APIServiceExportResource{ + {GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}}, + }, + reference: kubebindv1alpha2.SelectorReference{ + GroupResource: kubebindv1alpha2.GroupResource{Group: "mangodb.com", Resource: "mangodbs"}, + }, + shouldAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + export := &kubebindv1alpha2.APIServiceExport{ + Spec: kubebindv1alpha2.APIServiceExportSpec{ + ClusterScopedIsolation: tt.isolation, + Resources: tt.exportedResources, + }, + } + got := isReferenceAllowed(tt.reference, export) + if got != tt.shouldAllow { + t.Errorf("isReferenceAllowed() = %v, want %v", got, tt.shouldAllow) + } + }) + } +}