diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 29de46da9..1866d3b45 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -275,6 +275,10 @@ const ( // to the BackendSecurityPolicy whose targetRefs contains the AIServiceBackend. k8sClientIndexAIServiceBackendToTargetingBackendSecurityPolicy = "AIServiceBackendToTargetingBackendSecurityPolicy" + // k8sClientIndexReferenceGrantToTargetKind is the index name that maps from namespace/kind to ReferenceGrants, enabling efficient lookup of grants + // allowing access to specific resource types in specific namespaces. + k8sClientIndexReferenceGrantToTargetKind = "ReferenceGrantToTargetKind" + // Indexes for MCP Gateway // // k8sClientIndexMCPRouteToAttachedGateway is the index name that maps from a Gateway to the @@ -305,6 +309,13 @@ func ApplyIndexing(ctx context.Context, indexer func(ctx context.Context, obj cl return fmt.Errorf("failed to index field for BackendSecurityPolicy targetRefs: %w", err) } + // Apply indexes for ReferenceGrant. + err = indexer(ctx, &gwapiv1b1.ReferenceGrant{}, + k8sClientIndexReferenceGrantToTargetKind, referenceGrantToTargetKindIndexFunc) + if err != nil { + return fmt.Errorf("failed to create index from target kind to ReferenceGrant: %w", err) + } + // Apply indexes to MCP Gateways. err = indexer(ctx, &aigv1a1.MCPRoute{}, k8sClientIndexMCPRouteToAttachedGateway, mcpRouteToAttachedGatewayIndexFunc) @@ -408,6 +419,20 @@ func getSecretNameAndNamespace(secretRef *gwapiv1.SecretObjectReference, namespa return fmt.Sprintf("%s.%s", secretRef.Name, namespace) } +func getReferenceGrantIndexKey(namespace, kind string) string { + return fmt.Sprintf("%s.%s", namespace, kind) +} + +func referenceGrantToTargetKindIndexFunc(o client.Object) []string { + referenceGrant := o.(*gwapiv1b1.ReferenceGrant) + var keys []string + for _, to := range referenceGrant.Spec.To { + key := getReferenceGrantIndexKey(referenceGrant.Namespace, string(to.Kind)) + keys = append(keys, key) + } + return keys +} + // newConditions creates a new condition with the given type and message. // // Currently, we only set one condition at a time either "Accepted" or "NotAccepted". diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index 85829c94a..ab32b2a02 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -20,6 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1" aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" ) @@ -266,6 +267,250 @@ func Test_getSecretNameAndNamespace(t *testing.T) { require.Equal(t, "mysecret.foo", getSecretNameAndNamespace(secretRef, "foo")) } +func Test_referenceGrantToTargetKindIndexFunc(t *testing.T) { + tests := []struct { + name string + referenceGrant *gwapiv1b1.ReferenceGrant + expectedKeys []string + }{ + { + name: "single target kind - AIServiceBackend", + referenceGrant: &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant1", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIGatewayRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + }, + }, + }, + }, + expectedKeys: []string{"backend-ns.AIServiceBackend"}, + }, + { + name: "multiple target kinds", + referenceGrant: &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant2", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIGatewayRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + }, + { + Group: "", + Kind: "Secret", + }, + }, + }, + }, + expectedKeys: []string{"backend-ns.AIServiceBackend", "backend-ns.Secret"}, + }, + { + name: "empty group for core resources", + referenceGrant: &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant3", + Namespace: "other-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "gateway.networking.k8s.io", + Kind: "HTTPRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "", + Kind: "Service", + }, + }, + }, + }, + expectedKeys: []string{"other-ns.Service"}, + }, + { + name: "no target kinds", + referenceGrant: &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant4", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIGatewayRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{}, + }, + }, + expectedKeys: nil, // nil is returned for empty To array + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keys := referenceGrantToTargetKindIndexFunc(tt.referenceGrant) + require.Equal(t, tt.expectedKeys, keys) + }) + } +} + +func Test_referenceGrantIndexWithQuery(t *testing.T) { + // Create a fake client with the ReferenceGrant index + c := fake.NewClientBuilder(). + WithScheme(Scheme). + WithIndex(&gwapiv1b1.ReferenceGrant{}, k8sClientIndexReferenceGrantToTargetKind, referenceGrantToTargetKindIndexFunc). + Build() + + // Create multiple ReferenceGrants with different target kinds + grant1 := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-aiservicebackend", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIGatewayRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + }, + }, + }, + } + + grant2 := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-secret", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "gateway.networking.k8s.io", + Kind: "HTTPRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "", + Kind: "Secret", + }, + }, + }, + } + + grant3 := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-multiple", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIGatewayRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + }, + { + Group: "", + Kind: "Secret", + }, + }, + }, + } + + require.NoError(t, c.Create(t.Context(), grant1)) + require.NoError(t, c.Create(t.Context(), grant2)) + require.NoError(t, c.Create(t.Context(), grant3)) + + t.Run("query for AIServiceBackend grants in backend-ns", func(t *testing.T) { + var grants gwapiv1b1.ReferenceGrantList + err := c.List(t.Context(), &grants, + client.MatchingFields{k8sClientIndexReferenceGrantToTargetKind: "backend-ns.AIServiceBackend"}) + require.NoError(t, err) + + // Should find grant1 and grant3 (both allow AIServiceBackend in backend-ns) + require.Len(t, grants.Items, 2) + names := []string{grants.Items[0].Name, grants.Items[1].Name} + require.Contains(t, names, "grant-aiservicebackend") + require.Contains(t, names, "grant-multiple") + }) + + t.Run("query for Secret grants in backend-ns", func(t *testing.T) { + var grants gwapiv1b1.ReferenceGrantList + err := c.List(t.Context(), &grants, + client.MatchingFields{k8sClientIndexReferenceGrantToTargetKind: "backend-ns.Secret"}) + require.NoError(t, err) + + // Should find grant2 and grant3 (both allow Secret in backend-ns) + require.Len(t, grants.Items, 2) + names := []string{grants.Items[0].Name, grants.Items[1].Name} + require.Contains(t, names, "grant-secret") + require.Contains(t, names, "grant-multiple") + }) + + t.Run("query for non-existent kind", func(t *testing.T) { + var grants gwapiv1b1.ReferenceGrantList + err := c.List(t.Context(), &grants, + client.MatchingFields{k8sClientIndexReferenceGrantToTargetKind: "backend-ns.NonExistentKind"}) + require.NoError(t, err) + + // Should find nothing + require.Empty(t, grants.Items) + }) + + t.Run("query with wrong namespace", func(t *testing.T) { + var grants gwapiv1b1.ReferenceGrantList + err := c.List(t.Context(), &grants, + client.MatchingFields{k8sClientIndexReferenceGrantToTargetKind: "wrong-ns.AIServiceBackend"}) + require.NoError(t, err) + + // Should find nothing (wrong namespace) + require.Empty(t, grants.Items) + }) +} + func Test_handleFinalizer(t *testing.T) { tests := []struct { name string diff --git a/internal/controller/referencegrant.go b/internal/controller/referencegrant.go index 35963af5f..f5a47f280 100644 --- a/internal/controller/referencegrant.go +++ b/internal/controller/referencegrant.go @@ -54,10 +54,13 @@ func (v *referenceGrantValidator) validateAIServiceBackendReference( return nil } - // List all ReferenceGrants in the backend namespace + indexKey := getReferenceGrantIndexKey(backendNamespace, aiServiceBackendKind) var referenceGrants gwapiv1b1.ReferenceGrantList - if err := v.client.List(ctx, &referenceGrants, client.InNamespace(backendNamespace)); err != nil { - return fmt.Errorf("failed to list ReferenceGrants in namespace %s: %w", backendNamespace, err) + if err := v.client.List(ctx, &referenceGrants, + client.MatchingFields{k8sClientIndexReferenceGrantToTargetKind: indexKey}, + ); err != nil { + return fmt.Errorf("failed to list ReferenceGrants in namespace %s for kind %s: %w", + backendNamespace, aiServiceBackendKind, err) } // Check if any ReferenceGrant allows this cross-namespace reference diff --git a/internal/controller/referencegrant_test.go b/internal/controller/referencegrant_test.go index 2faa4e6fa..266d561f6 100644 --- a/internal/controller/referencegrant_test.go +++ b/internal/controller/referencegrant_test.go @@ -229,7 +229,7 @@ func TestReferenceGrantValidator_ValidateAIServiceBackendReference(t *testing.T) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create fake client with ReferenceGrants + // Create fake client with ReferenceGrants and the index objs := make([]client.Object, len(tt.referenceGrants)) for i := range tt.referenceGrants { objs[i] = &tt.referenceGrants[i] @@ -237,6 +237,7 @@ func TestReferenceGrantValidator_ValidateAIServiceBackendReference(t *testing.T) fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objs...). + WithIndex(&gwapiv1b1.ReferenceGrant{}, k8sClientIndexReferenceGrantToTargetKind, referenceGrantToTargetKindIndexFunc). Build() validator := newReferenceGrantValidator(fakeClient) @@ -371,3 +372,165 @@ func TestReferenceGrantValidator_MatchesTo_WrongKind(t *testing.T) { result := validator.matchesTo(to) require.False(t, result, "should return false for wrong kind") } + +// TestReferenceGrantValidator_WithIndex tests that the validator uses the index for efficient lookups +func TestReferenceGrantValidator_WithIndex(t *testing.T) { + scheme := runtime.NewScheme() + _ = gwapiv1b1.Install(scheme) + _ = aigv1a1.AddToScheme(scheme) + + // Create fake client with the ReferenceGrant index + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&gwapiv1b1.ReferenceGrant{}, k8sClientIndexReferenceGrantToTargetKind, referenceGrantToTargetKindIndexFunc). + Build() + + // Create multiple ReferenceGrants with different target kinds + grant1 := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-aiservicebackend", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: aiGatewayRouteKind, + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: aiServiceBackendGroup, + Kind: aiServiceBackendKind, + }, + }, + }, + } + + grant2 := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-secret", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: "gateway.networking.k8s.io", + Kind: "HTTPRoute", + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "", + Kind: "Secret", + }, + }, + }, + } + + require.NoError(t, fakeClient.Create(context.Background(), grant1)) + require.NoError(t, fakeClient.Create(context.Background(), grant2)) + + validator := newReferenceGrantValidator(fakeClient) + + t.Run("validates with index - AIServiceBackend allowed", func(t *testing.T) { + // This should succeed because grant1 allows AIGatewayRoute from route-ns to AIServiceBackend + err := validator.validateAIServiceBackendReference( + context.Background(), + "route-ns", + "backend-ns", + "test-backend", + ) + require.NoError(t, err) + }) + + t.Run("validates with index - different namespace denied", func(t *testing.T) { + // This should fail because no grant allows from "other-ns" + err := validator.validateAIServiceBackendReference( + context.Background(), + "other-ns", + "backend-ns", + "test-backend", + ) + require.Error(t, err) + require.Contains(t, err.Error(), "is not permitted") + }) + + t.Run("index filters out irrelevant grants", func(t *testing.T) { + // Verify that the index only returns AIServiceBackend grants, not Secret grants + // We can verify this indirectly by checking that validation works correctly + // even though grant2 (Secret) exists in the same namespace + + // Add another grant that allows AIGatewayRoute from other-ns but to Secret (not AIServiceBackend) + grant3 := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-other-ns-secret", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: aiGatewayRouteKind, + Namespace: "other-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: "", + Kind: "Secret", + }, + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), grant3)) + + // This should still fail because grant3 doesn't allow AIServiceBackend + err := validator.validateAIServiceBackendReference( + context.Background(), + "other-ns", + "backend-ns", + "test-backend", + ) + require.Error(t, err) + require.Contains(t, err.Error(), "is not permitted") + }) +} + +// TestGetReferenceGrantIndexKey tests the index key generation +func TestGetReferenceGrantIndexKey(t *testing.T) { + tests := []struct { + name string + namespace string + kind string + expectedKey string + }{ + { + name: "AIServiceBackend in backend-ns", + namespace: "backend-ns", + kind: "AIServiceBackend", + expectedKey: "backend-ns.AIServiceBackend", + }, + { + name: "Secret in default namespace", + namespace: "default", + kind: "Secret", + expectedKey: "default.Secret", + }, + { + name: "CustomResource in custom-ns", + namespace: "custom-ns", + kind: "CustomResource", + expectedKey: "custom-ns.CustomResource", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := getReferenceGrantIndexKey(tt.namespace, tt.kind) + require.Equal(t, tt.expectedKey, key) + }) + } +}