diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 70ba755ed4..4764e93740 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -259,7 +259,7 @@ func StartControllers(ctx context.Context, mgr manager.Manager, config *rest.Con } // ReferenceGrant controller for cross-namespace access validation - referenceGrantC := NewReferenceGrantController(c, logger.WithName("reference-grant"), aiGatewayRouteEventChan) + referenceGrantC := NewReferenceGrantController(c, logger.WithName("reference-grant"), aiGatewayRouteEventChan, backendSecurityPolicyEventChan) if err = TypedControllerBuilderForCRD(mgr, &gwapiv1b1.ReferenceGrant{}). Complete(referenceGrantC); err != nil { return fmt.Errorf("failed to create controller for ReferenceGrant: %w", err) @@ -472,37 +472,57 @@ func aiGatewayRouteIndexFunc(o client.Object) []string { func backendSecurityPolicyIndexFunc(o client.Object) []string { backendSecurityPolicy := o.(*aigv1b1.BackendSecurityPolicy) var key string - switch backendSecurityPolicy.Spec.Type { + if name, namespace, ok := backendSecurityPolicySecretRef(backendSecurityPolicy); ok { + key = fmt.Sprintf("%s.%s", name, namespace) + } else { + switch backendSecurityPolicy.Spec.Type { + case aigv1b1.BackendSecurityPolicyTypeAWSCredentials: + if backendSecurityPolicy.Spec.AWSCredentials.OIDCExchangeToken != nil { + key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name) + } + case aigv1b1.BackendSecurityPolicyTypeAzureCredentials: + if backendSecurityPolicy.Spec.AzureCredentials.OIDCExchangeToken != nil { + key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name) + } + } + } + return []string{key} +} + +// backendSecurityPolicySecretRef returns the name and namespace of the Secret directly referenced by a +// BackendSecurityPolicy's static credential fields, and whether one is set. OIDC-based credential types +// don't have a fixed target Secret here (see backendSecurityPolicyIndexFunc, which keys those by the +// BackendSecurityPolicy itself instead) and are excluded. +func backendSecurityPolicySecretRef(bsp *aigv1b1.BackendSecurityPolicy) (name, namespace string, ok bool) { + var secretRef *gwapiv1.SecretObjectReference + switch bsp.Spec.Type { case aigv1b1.BackendSecurityPolicyTypeAPIKey: - apiKey := backendSecurityPolicy.Spec.APIKey - key = getSecretNameAndNamespace(apiKey.SecretRef, backendSecurityPolicy.Namespace) + secretRef = bsp.Spec.APIKey.SecretRef case aigv1b1.BackendSecurityPolicyTypeAWSCredentials: - awsCreds := backendSecurityPolicy.Spec.AWSCredentials - if awsCreds.CredentialsFile != nil { - key = getSecretNameAndNamespace(awsCreds.CredentialsFile.SecretRef, backendSecurityPolicy.Namespace) - } else if awsCreds.OIDCExchangeToken != nil { - key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name) + if bsp.Spec.AWSCredentials.CredentialsFile != nil { + secretRef = bsp.Spec.AWSCredentials.CredentialsFile.SecretRef } case aigv1b1.BackendSecurityPolicyTypeGCPCredentials: - gcpCreds := backendSecurityPolicy.Spec.GCPCredentials - if gcpCreds.CredentialsFile != nil { - key = getSecretNameAndNamespace(gcpCreds.CredentialsFile.SecretRef, backendSecurityPolicy.Namespace) + if bsp.Spec.GCPCredentials.CredentialsFile != nil { + secretRef = bsp.Spec.GCPCredentials.CredentialsFile.SecretRef } case aigv1b1.BackendSecurityPolicyTypeAzureAPIKey: - apiKey := backendSecurityPolicy.Spec.AzureAPIKey - key = getSecretNameAndNamespace(apiKey.SecretRef, backendSecurityPolicy.Namespace) + secretRef = bsp.Spec.AzureAPIKey.SecretRef case aigv1b1.BackendSecurityPolicyTypeAnthropicAPIKey: - apiKey := backendSecurityPolicy.Spec.AnthropicAPIKey - key = getSecretNameAndNamespace(apiKey.SecretRef, backendSecurityPolicy.Namespace) + secretRef = bsp.Spec.AnthropicAPIKey.SecretRef case aigv1b1.BackendSecurityPolicyTypeAzureCredentials: - azureCreds := backendSecurityPolicy.Spec.AzureCredentials - if azureCreds.ClientSecretRef != nil { - key = getSecretNameAndNamespace(azureCreds.ClientSecretRef, backendSecurityPolicy.Namespace) - } else if azureCreds.OIDCExchangeToken != nil { - key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name) + if bsp.Spec.AzureCredentials.ClientSecretRef != nil { + secretRef = bsp.Spec.AzureCredentials.ClientSecretRef } } - return []string{key} + if secretRef == nil { + return "", "", false + } + namespace = bsp.Namespace + if secretRef.Namespace != nil { + namespace = string(*secretRef.Namespace) + } + return string(secretRef.Name), namespace, true } func backendSecurityPolicyTargetRefsIndexFunc(o client.Object) []string { @@ -523,13 +543,6 @@ func quotaPolicyTargetRefsIndexFunc(o client.Object) []string { return ret } -func getSecretNameAndNamespace(secretRef *gwapiv1.SecretObjectReference, namespace string) string { - if secretRef.Namespace != nil { - return fmt.Sprintf("%s.%s", secretRef.Name, *secretRef.Namespace) - } - return fmt.Sprintf("%s.%s", secretRef.Name, namespace) -} - func getReferenceGrantIndexKey(namespace, kind string) string { return fmt.Sprintf("%s.%s", namespace, kind) } diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index 9f837cc452..c9a36e1225 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -257,16 +257,6 @@ func Test_backendSecurityPolicyIndexFunc(t *testing.T) { } } -func Test_getSecretNameAndNamespace(t *testing.T) { - secretRef := &gwapiv1.SecretObjectReference{ - Name: "mysecret", - Namespace: ptr.To[gwapiv1.Namespace]("default"), - } - require.Equal(t, "mysecret.default", getSecretNameAndNamespace(secretRef, "foo")) - secretRef.Namespace = nil - require.Equal(t, "mysecret.foo", getSecretNameAndNamespace(secretRef, "foo")) -} - func Test_referenceGrantToTargetKindIndexFunc(t *testing.T) { tests := []struct { name string diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index ab0c3ee4c4..5cb2630cf6 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -61,13 +61,14 @@ func NewGatewayController( uf = uuid.NewString } return &GatewayController{ - client: client, - kube: kube, - logger: logger, - envoyGatewayNamespace: envoyGatewayNamespace, - standAlone: standAlone, - uuidFn: uf, - extProcBuilder: newExtProcBuilder(options, extProcAsSideCar, logger), + client: client, + kube: kube, + logger: logger, + envoyGatewayNamespace: envoyGatewayNamespace, + standAlone: standAlone, + uuidFn: uf, + extProcBuilder: newExtProcBuilder(options, extProcAsSideCar, logger), + referenceGrantValidator: newReferenceGrantValidator(client), } } @@ -83,6 +84,9 @@ type GatewayController struct { // extProcBuilder is shared with the mutating webhook so the template hash // computed here matches the extproc container injected by the webhook. *extProcBuilder + // referenceGrantValidator authorizes cross-namespace AIServiceBackend/InferencePool + // references (and their BackendSecurityPolicy credentials) via Gateway API ReferenceGrant. + referenceGrantValidator *referenceGrantValidator } // Reconcile implements the reconcile.Reconciler for gwapiv1.Gateway. @@ -483,6 +487,23 @@ func (c *GatewayController) reconcileFilterConfigSecret( var bsp *aigv1b1.BackendSecurityPolicy backendNamespace := backendRef.GetNamespace(aiGatewayRoute.Namespace) + if backendRef.IsCrossNamespace(aiGatewayRoute.Namespace) { + var rgErr error + if backendRef.IsInferencePool() { + rgErr = c.referenceGrantValidator.validateInferencePoolReference( + ctx, aiGatewayRoute.Namespace, backendNamespace, backendRef.Name) + } else { + rgErr = c.referenceGrantValidator.validateAIServiceBackendReference( + ctx, aiGatewayRoute.Namespace, backendNamespace, backendRef.Name) + } + if rgErr != nil { + c.logger.Error(rgErr, "cross-namespace backendRef rejected: no valid ReferenceGrant. Skipping this backend.", + "backend_name", backendRef.Name, "aigatewayroute", aiGatewayRoute.Name, + "namespace", backendNamespace) + continue + } + } + if backendRef.IsInferencePool() { // We assume that InferencePools are all OpenAI schema. b.Schema = filterapi.VersionedAPISchema{ diff --git a/internal/controller/gateway_test.go b/internal/controller/gateway_test.go index f7184716ee..e69e6485c8 100644 --- a/internal/controller/gateway_test.go +++ b/internal/controller/gateway_test.go @@ -36,6 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1" aigv1b1 "github.com/envoyproxy/ai-gateway/api/v1beta1" "github.com/envoyproxy/ai-gateway/internal/controller/rotators" @@ -500,6 +501,149 @@ func TestGatewayController_reconcileFilterConfigSecret(t *testing.T) { } } +// GatewayController must not include a cross-namespace AIServiceBackend/InferencePool +// (and its BackendSecurityPolicy credentials) in the extproc filter config unless a ReferenceGrant permits it. +func TestGatewayController_reconcileFilterConfigSecret_CrossNamespaceReferenceGrant(t *testing.T) { + const ( + routeNamespace = "tenant-a" + backendNamespace = "tenant-b" + ) + + newRoute := func(kind, name string) []aigv1b1.AIGatewayRoute { + group := aiServiceBackendGroup + if kind == "InferencePool" { + group = inferencePoolGroup + } + return []aigv1b1.AIGatewayRoute{ + { + ObjectMeta: metav1.ObjectMeta{Name: "route1", Namespace: routeNamespace}, + Spec: aigv1b1.AIGatewayRouteSpec{ + Rules: []aigv1b1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1b1.AIGatewayRouteRuleBackendRef{ + { + Name: name, + Namespace: ptr.To(gwapiv1.Namespace(backendNamespace)), + Group: ptr.To(group), + Kind: ptr.To(kind), + }, + }, + }, + }, + }, + }, + } + } + + referenceGrant := func(fromGroup, fromKind, toGroup, toKind string) *gwapiv1b1.ReferenceGrant { + return &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{Name: "allow", Namespace: backendNamespace}, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{{Group: gwapiv1b1.Group(fromGroup), Kind: gwapiv1b1.Kind(fromKind), Namespace: routeNamespace}}, + To: []gwapiv1b1.ReferenceGrantTo{{Group: gwapiv1b1.Group(toGroup), Kind: gwapiv1b1.Kind(toKind)}}, + }, + } + } + + setup := func(t *testing.T, grant *gwapiv1b1.ReferenceGrant) (*GatewayController, kubernetes.Interface) { + t.Helper() + fakeClient := requireNewFakeClientWithIndexes(t) + kube := fake2.NewClientset() + c := newTestGatewayController(fakeClient, kube, ctrl.Log, "envoy-gateway-system", + "docker.io/envoyproxy/ai-gateway-extproc:latest", "info", false, nil, true) + + require.NoError(t, fakeClient.Create(t.Context(), &aigv1b1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{Name: "backend1", Namespace: backendNamespace}, + Spec: aigv1b1.AIServiceBackendSpec{ + BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To[gwapiv1.Namespace](backendNamespace)}, + }, + })) + require.NoError(t, fakeClient.Create(t.Context(), &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "bsp", Namespace: backendNamespace}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "api-key-secret"}, + }, + TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{ + {Kind: "AIServiceBackend", Group: "aigateway.envoyproxy.io", Name: "backend1"}, + }, + }, + })) + _, err := kube.CoreV1().Secrets(backendNamespace).Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "api-key-secret", Namespace: backendNamespace}, + Data: map[string][]byte{apiKeyInSecret: []byte("supersecret")}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + if grant != nil { + require.NoError(t, fakeClient.Create(t.Context(), grant)) + } + return c, kube + } + + t.Run("AIServiceBackend cross-namespace without ReferenceGrant is rejected", func(t *testing.T) { + c, kube := setup(t, nil) + const someNamespace = "some-namespace" + _, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace, + newRoute("AIServiceBackend", "backend1"), nil, "uuid", nil, nil) + require.NoError(t, err) + + fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace) + require.Empty(t, fc.Backends, "cross-namespace backend without a ReferenceGrant must not be wired into the filter config") + }) + + t.Run("AIServiceBackend cross-namespace with ReferenceGrant is allowed", func(t *testing.T) { + grant := referenceGrant(aiServiceBackendGroup, aiGatewayRouteKind, aiServiceBackendGroup, aiServiceBackendKind) + c, kube := setup(t, grant) + const someNamespace = "some-namespace" + _, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace, + newRoute("AIServiceBackend", "backend1"), nil, "uuid", nil, nil) + require.NoError(t, err) + + fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace) + require.Len(t, fc.Backends, 1) + require.NotNil(t, fc.Backends[0].Auth) + require.NotNil(t, fc.Backends[0].Auth.APIKey) + require.Equal(t, "supersecret", fc.Backends[0].Auth.APIKey.Key) + }) + + t.Run("InferencePool cross-namespace without ReferenceGrant is rejected", func(t *testing.T) { + c, kube := setup(t, nil) + const someNamespace = "some-namespace" + _, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace, + newRoute("InferencePool", "pool1"), nil, "uuid", nil, nil) + require.NoError(t, err) + + fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace) + require.Empty(t, fc.Backends, "cross-namespace inference pool without a ReferenceGrant must not be wired into the filter config") + }) + + t.Run("InferencePool cross-namespace with ReferenceGrant is allowed", func(t *testing.T) { + grant := referenceGrant(aiServiceBackendGroup, aiGatewayRouteKind, inferencePoolGroup, inferencePoolKind) + c, kube := setup(t, grant) + + // Re-target the existing BSP at the InferencePool instead of the AIServiceBackend. + var bsp aigv1b1.BackendSecurityPolicy + require.NoError(t, c.client.Get(t.Context(), client.ObjectKey{Namespace: backendNamespace, Name: "bsp"}, &bsp)) + bsp.Spec.TargetRefs = []gwapiv1a2.LocalPolicyTargetReference{ + {Kind: "InferencePool", Group: "inference.networking.k8s.io", Name: "pool1"}, + } + require.NoError(t, c.client.Update(t.Context(), &bsp)) + + const someNamespace = "some-namespace" + _, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace, + newRoute("InferencePool", "pool1"), nil, "uuid", nil, nil) + require.NoError(t, err) + + fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace) + require.Len(t, fc.Backends, 1) + require.NotNil(t, fc.Backends[0].Auth) + require.NotNil(t, fc.Backends[0].Auth.APIKey) + require.Equal(t, "supersecret", fc.Backends[0].Auth.APIKey.Key) + }) +} + // TestGatewayController_reconcileFilterConfigSecret_HostnameScopedModels verifies that mixing routes // with and without Spec.Hostnames produces a filter config where: // - each per-host list contains the host's own models AND every unscoped model (so the unscoped @@ -3332,10 +3476,8 @@ func Test_mcpConfig_ForwardHeaders(t *testing.T) { require.Empty(t, backendB.ForwardHeaders) } -// Test_mcpConfig_Authorization_VerifiedJWT is a regression test for GHSA-9mp9-4hr2-835f -// (MCPRoute CEL Authorization Bypass via Unverified JWT Claims). The CRD's XValidation rule -// only requires oauth when a rule's source.jwt is set; a CEL expression referencing -// request.auth.jwt.claims/scopes is accepted without oauth configured. mcpConfig must still +// The CRD's XValidation rule only requires oauth when a rule's source.jwt is set; a CEL expression +// referencing request.auth.jwt.claims/scopes is accepted without oauth configured. mcpConfig must still // mark the resulting filterapi.MCPRouteAuthorization as VerifiedJWT: false in that case, so // the mcpproxy never trusts an attacker-forged bearer JWT for such a rule. func Test_mcpConfig_Authorization_VerifiedJWT(t *testing.T) { diff --git a/internal/controller/referencegrant_controller.go b/internal/controller/referencegrant_controller.go index 4d5142cd42..9f349e3c70 100644 --- a/internal/controller/referencegrant_controller.go +++ b/internal/controller/referencegrant_controller.go @@ -22,13 +22,14 @@ import ( // ReferenceGrantController implements [reconcile.TypedReconciler] for ReferenceGrant. // // This controller watches ReferenceGrant resources and triggers reconciliation of -// affected AIGatewayRoutes when grants are created, updated, or deleted. +// affected AIGatewayRoutes and BackendSecurityPolicies when grants are created, updated, or deleted. // // Exported for testing purposes. type ReferenceGrantController struct { - client client.Client - logger logr.Logger - aiGatewayRouteChan chan event.GenericEvent + client client.Client + logger logr.Logger + aiGatewayRouteChan chan event.GenericEvent + backendSecurityPolicyChan chan event.GenericEvent } // NewReferenceGrantController creates a new [reconcile.TypedReconciler] for ReferenceGrant. @@ -36,11 +37,13 @@ func NewReferenceGrantController( c client.Client, logger logr.Logger, aiGatewayRouteChan chan event.GenericEvent, + backendSecurityPolicyChan chan event.GenericEvent, ) *ReferenceGrantController { return &ReferenceGrantController{ - client: c, - logger: logger, - aiGatewayRouteChan: aiGatewayRouteChan, + client: c, + logger: logger, + aiGatewayRouteChan: aiGatewayRouteChan, + backendSecurityPolicyChan: backendSecurityPolicyChan, } } @@ -51,22 +54,35 @@ func (c *ReferenceGrantController) Reconcile(ctx context.Context, req reconcile. var referenceGrant gwapiv1b1.ReferenceGrant if err := c.client.Get(ctx, req.NamespacedName, &referenceGrant); err != nil { if client.IgnoreNotFound(err) == nil { - // ReferenceGrant was deleted, need to reconcile affected routes - c.logger.Info("ReferenceGrant deleted, reconciling affected AIGatewayRoutes", - "namespace", req.Namespace, "name", req.Name) - // We can't determine affected routes without the grant object, - // so we rely on the AIGatewayRoute controller to handle the validation failure + // The ReferenceGrant is already gone, e.g. it was removed before the finalizer below + // could be attached. There's no spec left to determine what it used to authorize. return ctrl.Result{}, nil } return ctrl.Result{}, err } + // A finalizer keeps the ReferenceGrant retrievable (with DeletionTimestamp set) until + // triggerAffectedReconciles has run, so a deleted grant still wakes up everything it used to + // authorize instead of leaving them with stale, now-invalid access. + if handleFinalizer(ctx, c.client, c.logger, &referenceGrant, c.triggerAffectedReconciles) { + return ctrl.Result{}, nil + } + + if err := c.triggerAffectedReconciles(ctx, &referenceGrant); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// triggerAffectedReconciles triggers reconciliation of every AIGatewayRoute and BackendSecurityPolicy +// that referenceGrant affects, whether it was just created, updated, or is about to be deleted. +func (c *ReferenceGrantController) triggerAffectedReconciles(ctx context.Context, referenceGrant *gwapiv1b1.ReferenceGrant) error { // Get all AIGatewayRoutes that might be affected by this ReferenceGrant - affectedRoutes, err := c.getAffectedAIGatewayRoutes(ctx, &referenceGrant) + affectedRoutes, err := c.getAffectedAIGatewayRoutes(ctx, referenceGrant) if err != nil { c.logger.Error(err, "failed to get affected AIGatewayRoutes", "namespace", referenceGrant.Namespace, "name", referenceGrant.Name) - return ctrl.Result{}, err + return err } // Trigger reconciliation for each affected AIGatewayRoute @@ -77,7 +93,23 @@ func (c *ReferenceGrantController) Reconcile(ctx context.Context, req reconcile. c.aiGatewayRouteChan <- event.GenericEvent{Object: route} } - return reconcile.Result{}, nil + // Get all BackendSecurityPolicies that might be affected by this ReferenceGrant + affectedBackendSecurityPolicies, err := c.getAffectedBackendSecurityPolicies(ctx, referenceGrant) + if err != nil { + c.logger.Error(err, "failed to get affected BackendSecurityPolicies", + "namespace", referenceGrant.Namespace, "name", referenceGrant.Name) + return err + } + + // Trigger reconciliation for each affected BackendSecurityPolicy + for _, bsp := range affectedBackendSecurityPolicies { + c.logger.Info("Triggering reconciliation for affected BackendSecurityPolicy", + "backendsecuritypolicy_namespace", bsp.Namespace, "backendsecuritypolicy_name", bsp.Name, + "grant_namespace", referenceGrant.Namespace, "grant_name", referenceGrant.Name) + c.backendSecurityPolicyChan <- event.GenericEvent{Object: bsp} + } + + return nil } // getAffectedAIGatewayRoutes returns all AIGatewayRoutes that might be affected by a ReferenceGrant change. @@ -127,3 +159,42 @@ func (c *ReferenceGrantController) routeReferencesNamespace(route *aigv1b1.AIGat } return false } + +// getAffectedBackendSecurityPolicies returns all BackendSecurityPolicies that might be affected by a +// ReferenceGrant change. This is used to trigger reconciliation when a ReferenceGrant is created, updated, or deleted. +func (c *ReferenceGrantController) getAffectedBackendSecurityPolicies( + ctx context.Context, + grant *gwapiv1b1.ReferenceGrant, +) ([]*aigv1b1.BackendSecurityPolicy, error) { + var affectedBackendSecurityPolicies []*aigv1b1.BackendSecurityPolicy + + // For each "from" reference in the grant, find BackendSecurityPolicies in that namespace + // that might reference Secrets in the grant's namespace + for _, from := range grant.Spec.From { + if from.Group != aiServiceBackendGroup || from.Kind != backendSecurityPolicyKind { + continue + } + + var policies aigv1b1.BackendSecurityPolicyList + if err := c.client.List(ctx, &policies, client.InNamespace(string(from.Namespace))); err != nil { + return nil, fmt.Errorf("failed to list BackendSecurityPolicies in namespace %s: %w", from.Namespace, err) + } + + // Check if any of these policies reference secrets in the grant's namespace + for i := range policies.Items { + bsp := &policies.Items[i] + if c.backendSecurityPolicyReferencesNamespace(bsp, grant.Namespace) { + affectedBackendSecurityPolicies = append(affectedBackendSecurityPolicies, bsp) + } + } + } + + return affectedBackendSecurityPolicies, nil +} + +// backendSecurityPolicyReferencesNamespace checks if a BackendSecurityPolicy has a Secret reference to a +// specific namespace. +func (c *ReferenceGrantController) backendSecurityPolicyReferencesNamespace(bsp *aigv1b1.BackendSecurityPolicy, namespace string) bool { + _, secretNamespace, ok := backendSecurityPolicySecretRef(bsp) + return ok && secretNamespace == namespace +} diff --git a/internal/controller/referencegrant_controller_test.go b/internal/controller/referencegrant_controller_test.go index ef39932263..f52d62030c 100644 --- a/internal/controller/referencegrant_controller_test.go +++ b/internal/controller/referencegrant_controller_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-logr/logr" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" @@ -78,9 +79,10 @@ func TestReferenceGrantController_Reconcile(t *testing.T) { // Create a buffered channel to avoid blocking aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) req := reconcile.Request{ NamespacedName: client.ObjectKeyFromObject(referenceGrant), @@ -104,9 +106,10 @@ func TestReferenceGrantController_Reconcile(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) req := reconcile.Request{ NamespacedName: client.ObjectKey{ @@ -123,6 +126,90 @@ func TestReferenceGrantController_Reconcile(t *testing.T) { require.Empty(t, aiGatewayRouteChan) }) + t.Run("ReferenceGrant being deleted - triggers affected AIGatewayRoutes before removal", func(t *testing.T) { + referenceGrant := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-grant", + Namespace: "backend-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: aiGatewayRouteKind, + Namespace: "route-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: aiServiceBackendGroup, + Kind: aiServiceBackendKind, + }, + }, + }, + } + + affectedRoute := &aigv1b1.AIGatewayRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "affected-route", + Namespace: "route-ns", + }, + Spec: aigv1b1.AIGatewayRouteSpec{ + Rules: []aigv1b1.AIGatewayRouteRule{ + { + BackendRefs: []aigv1b1.AIGatewayRouteRuleBackendRef{ + { + Name: "backend", + Namespace: ptr.To(gwapiv1.Namespace("backend-ns")), + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(referenceGrant, affectedRoute). + Build() + + aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) + logger := logr.Discard() + + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) + + req := reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(referenceGrant), + } + + // First reconcile adds the finalizer and fires the "created" event. + _, err := controller.Reconcile(context.Background(), req) + require.NoError(t, err) + require.Len(t, aiGatewayRouteChan, 1) + <-aiGatewayRouteChan + + var grant gwapiv1b1.ReferenceGrant + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKeyFromObject(referenceGrant), &grant)) + require.Contains(t, grant.Finalizers, aiGatewayControllerFinalizer) + + // Deleting the grant should not remove it right away since the finalizer is present. + require.NoError(t, fakeClient.Delete(context.Background(), &grant)) + + // Second reconcile should still see the grant (deletion in progress), fire the event for the + // route it used to authorize, and then remove the finalizer so the grant is actually deleted. + result, err := controller.Reconcile(context.Background(), req) + require.NoError(t, err) + require.Equal(t, reconcile.Result{}, result) + + require.Len(t, aiGatewayRouteChan, 1) + got := <-aiGatewayRouteChan + require.Equal(t, affectedRoute.Name, got.Object.GetName()) + + err = fakeClient.Get(context.Background(), client.ObjectKeyFromObject(referenceGrant), &grant) + require.True(t, apierrors.IsNotFound(err)) + }) + t.Run("ReferenceGrant with no affected routes", func(t *testing.T) { referenceGrant := &gwapiv1b1.ReferenceGrant{ ObjectMeta: metav1.ObjectMeta{ @@ -152,9 +239,10 @@ func TestReferenceGrantController_Reconcile(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) req := reconcile.Request{ NamespacedName: client.ObjectKeyFromObject(referenceGrant), @@ -235,9 +323,10 @@ func TestReferenceGrantController_Reconcile(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) req := reconcile.Request{ NamespacedName: client.ObjectKeyFromObject(referenceGrant), @@ -272,14 +361,16 @@ func TestNewReferenceGrantController(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) require.NotNil(t, controller) require.Equal(t, fakeClient, controller.client) require.Equal(t, logger, controller.logger) require.Equal(t, aiGatewayRouteChan, controller.aiGatewayRouteChan) + require.Equal(t, backendSecurityPolicyChan, controller.backendSecurityPolicyChan) } // TestReferenceGrantController_Reconcile_GetError tests reconcile when Get returns error @@ -294,9 +385,10 @@ func TestReferenceGrantController_Reconcile_GetError(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) // Try to reconcile a non-existent ReferenceGrant - this should be handled gracefully req := reconcile.Request{ @@ -345,9 +437,10 @@ func TestReferenceGrantController_Reconcile_GetAffectedRoutesError(t *testing.T) Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) req := reconcile.Request{ NamespacedName: client.ObjectKeyFromObject(referenceGrant), @@ -537,8 +630,9 @@ func TestReferenceGrantController_GetAffectedAIGatewayRoutes(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) affectedRoutes, err := controller.getAffectedAIGatewayRoutes( context.Background(), @@ -565,8 +659,9 @@ func TestReferenceGrantController_GetAffectedAIGatewayRoutes(t *testing.T) { Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) grant := &gwapiv1b1.ReferenceGrant{ ObjectMeta: metav1.ObjectMeta{ @@ -605,8 +700,9 @@ func TestReferenceGrantController_GetAffectedAIGatewayRoutes_WithNonMatchingFrom fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) logger := logr.Discard() - controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan) + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) grant := &gwapiv1b1.ReferenceGrant{ ObjectMeta: metav1.ObjectMeta{ @@ -634,3 +730,299 @@ func TestReferenceGrantController_GetAffectedAIGatewayRoutes_WithNonMatchingFrom require.NoError(t, err) require.Empty(t, routes, "should not return any routes when From doesn't match") } + +func TestReferenceGrantController_Reconcile_BackendSecurityPolicy(t *testing.T) { + scheme := runtime.NewScheme() + _ = gwapiv1b1.Install(scheme) + _ = aigv1b1.AddToScheme(scheme) + + t.Run("ReferenceGrant created - triggers affected BackendSecurityPolicy", func(t *testing.T) { + referenceGrant := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-grant", + Namespace: "secret-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: backendSecurityPolicyKind, + Namespace: "bsp-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: secretGroup, + Kind: secretKind, + }, + }, + }, + } + + affectedBSP := &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "affected-bsp", + Namespace: "bsp-ns", + }, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{ + Name: "api-key-secret", + Namespace: ptr.To(gwapiv1.Namespace("secret-ns")), + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(referenceGrant, affectedBSP). + Build() + + aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) + logger := logr.Discard() + + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) + + req := reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(referenceGrant), + } + + result, err := controller.Reconcile(context.Background(), req) + require.NoError(t, err) + require.Equal(t, reconcile.Result{}, result) + + require.Empty(t, aiGatewayRouteChan) + require.Len(t, backendSecurityPolicyChan, 1) + got := <-backendSecurityPolicyChan + require.Equal(t, affectedBSP.Name, got.Object.GetName()) + require.Equal(t, affectedBSP.Namespace, got.Object.GetNamespace()) + }) + + t.Run("ReferenceGrant unrelated to any BackendSecurityPolicy triggers no event", func(t *testing.T) { + referenceGrant := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-grant", + Namespace: "secret-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: backendSecurityPolicyKind, + Namespace: "bsp-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: secretGroup, + Kind: secretKind, + }, + }, + }, + } + + // BSP's secret is in a different namespace than the grant, so it is unaffected. + unaffectedBSP := &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unaffected-bsp", + Namespace: "bsp-ns", + }, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{ + Name: "api-key-secret", + Namespace: ptr.To(gwapiv1.Namespace("other-secret-ns")), + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(referenceGrant, unaffectedBSP). + Build() + + aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) + logger := logr.Discard() + + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) + + req := reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(referenceGrant), + } + + result, err := controller.Reconcile(context.Background(), req) + require.NoError(t, err) + require.Equal(t, reconcile.Result{}, result) + + require.Empty(t, backendSecurityPolicyChan) + }) + + t.Run("ReferenceGrant being deleted - triggers affected BackendSecurityPolicies before removal", func(t *testing.T) { + referenceGrant := &gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-grant", + Namespace: "secret-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: backendSecurityPolicyKind, + Namespace: "bsp-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: secretGroup, + Kind: secretKind, + }, + }, + }, + } + + affectedBSP := &aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "affected-bsp", + Namespace: "bsp-ns", + }, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{ + Name: "api-key-secret", + Namespace: ptr.To(gwapiv1.Namespace("secret-ns")), + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(referenceGrant, affectedBSP). + Build() + + aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) + logger := logr.Discard() + + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) + + req := reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(referenceGrant), + } + + // First reconcile adds the finalizer and fires the "created" event. + _, err := controller.Reconcile(context.Background(), req) + require.NoError(t, err) + require.Len(t, backendSecurityPolicyChan, 1) + <-backendSecurityPolicyChan + + var grant gwapiv1b1.ReferenceGrant + require.NoError(t, fakeClient.Get(context.Background(), client.ObjectKeyFromObject(referenceGrant), &grant)) + require.Contains(t, grant.Finalizers, aiGatewayControllerFinalizer) + + // Deleting the grant should not remove it right away since the finalizer is present. + require.NoError(t, fakeClient.Delete(context.Background(), &grant)) + + // Second reconcile should still see the grant (deletion in progress), fire the event for the + // BackendSecurityPolicy it used to authorize, and then remove the finalizer so the grant is + // actually deleted. + result, err := controller.Reconcile(context.Background(), req) + require.NoError(t, err) + require.Equal(t, reconcile.Result{}, result) + + require.Len(t, backendSecurityPolicyChan, 1) + got := <-backendSecurityPolicyChan + require.Equal(t, affectedBSP.Name, got.Object.GetName()) + + err = fakeClient.Get(context.Background(), client.ObjectKeyFromObject(referenceGrant), &grant) + require.True(t, apierrors.IsNotFound(err)) + }) +} + +func TestReferenceGrantController_GetAffectedBackendSecurityPolicies(t *testing.T) { + scheme := runtime.NewScheme() + _ = gwapiv1b1.Install(scheme) + _ = aigv1b1.AddToScheme(scheme) + + grant := gwapiv1b1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-grant", + Namespace: "secret-ns", + }, + Spec: gwapiv1b1.ReferenceGrantSpec{ + From: []gwapiv1b1.ReferenceGrantFrom{ + { + Group: aiServiceBackendGroup, + Kind: backendSecurityPolicyKind, + Namespace: "bsp-ns", + }, + }, + To: []gwapiv1b1.ReferenceGrantTo{ + { + Group: secretGroup, + Kind: secretKind, + }, + }, + }, + } + + affectedBSP := aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "affected-bsp", Namespace: "bsp-ns"}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{ + Name: "api-key-secret", + Namespace: ptr.To(gwapiv1.Namespace("secret-ns")), + }, + }, + }, + } + sameNamespaceBSP := aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "same-ns-bsp", Namespace: "bsp-ns"}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{Name: "local-secret"}, + }, + }, + } + otherNamespaceBSP := aigv1b1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "other-ns-bsp", Namespace: "other-bsp-ns"}, + Spec: aigv1b1.BackendSecurityPolicySpec{ + Type: aigv1b1.BackendSecurityPolicyTypeAPIKey, + APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{ + SecretRef: &gwapiv1.SecretObjectReference{ + Name: "api-key-secret", + Namespace: ptr.To(gwapiv1.Namespace("secret-ns")), + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&affectedBSP, &sameNamespaceBSP, &otherNamespaceBSP). + Build() + + aiGatewayRouteChan := make(chan event.GenericEvent, 10) + backendSecurityPolicyChan := make(chan event.GenericEvent, 10) + logger := logr.Discard() + controller := NewReferenceGrantController(fakeClient, logger, aiGatewayRouteChan, backendSecurityPolicyChan) + + affected, err := controller.getAffectedBackendSecurityPolicies(context.Background(), &grant) + require.NoError(t, err) + + names := make([]string, len(affected)) + for i, bsp := range affected { + names[i] = bsp.Name + } + require.ElementsMatch(t, []string{"affected-bsp"}, names) +}