This is an automated email from the ASF dual-hosted git repository.

shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new 843879ca fix: enforce ReferenceGrant on cross-namespace Consumer 
SecretRef (#2805)
843879ca is described below

commit 843879ca735f5ac4144518aae82c47d4bd0128ce
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Fri Jul 24 00:47:29 2026 +0800

    fix: enforce ReferenceGrant on cross-namespace Consumer SecretRef (#2805)
---
 internal/controller/consumer_controller.go      |  22 ++++
 internal/controller/consumer_controller_test.go | 148 ++++++++++++++++++++++++
 internal/webhook/v1/consumer_webhook_test.go    |  69 ++++++++++-
 3 files changed, 235 insertions(+), 4 deletions(-)

diff --git a/internal/controller/consumer_controller.go 
b/internal/controller/consumer_controller.go
index 5ef72bbf..2c0a4eb0 100644
--- a/internal/controller/consumer_controller.go
+++ b/internal/controller/consumer_controller.go
@@ -35,6 +35,7 @@ import (
        "sigs.k8s.io/controller-runtime/pkg/predicate"
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+       "sigs.k8s.io/gateway-api/apis/v1beta1"
 
        "github.com/apache/apisix-ingress-controller/api/v1alpha1"
        "github.com/apache/apisix-ingress-controller/internal/controller/config"
@@ -253,6 +254,27 @@ func (r *ConsumerReconciler) processSpec(ctx 
context.Context, tctx *provider.Tra
                if credential.SecretRef.Namespace != nil {
                        ns = *credential.SecretRef.Namespace
                }
+               // A cross-namespace SecretRef needs a ReferenceGrant, same as 
routes.
+               secretNS := gatewayv1.Namespace(ns)
+               if permitted := checkReferenceGrant(ctx,
+                       r.Client,
+                       v1beta1.ReferenceGrantFrom{
+                               Group:     
v1beta1.Group(v1alpha1.GroupVersion.Group),
+                               Kind:      
v1beta1.Kind(internaltypes.KindConsumer),
+                               Namespace: 
v1beta1.Namespace(consumer.GetNamespace()),
+                       },
+                       gatewayv1.ObjectReference{
+                               Group:     corev1.GroupName,
+                               Kind:      KindSecret,
+                               Name:      
gatewayv1.ObjectName(credential.SecretRef.Name),
+                               Namespace: &secretNS,
+                       },
+               ); !permitted {
+                       r.Log.Error(nil, "cross-namespace secret reference not 
permitted by any ReferenceGrant",
+                               "consumer", utils.NamespacedName(consumer), 
"secret", client.ObjectKey{Namespace: ns, Name: credential.SecretRef.Name})
+                       return fmt.Errorf("cross-namespace secret reference 
from Consumer %s/%s to Secret %s/%s is not permitted by any ReferenceGrant",
+                               consumer.GetNamespace(), consumer.GetName(), 
ns, credential.SecretRef.Name)
+               }
                secret := corev1.Secret{}
                if err := r.Get(ctx, client.ObjectKey{
                        Name:      credential.SecretRef.Name,
diff --git a/internal/controller/consumer_controller_test.go 
b/internal/controller/consumer_controller_test.go
new file mode 100644
index 00000000..4194d8d9
--- /dev/null
+++ b/internal/controller/consumer_controller_test.go
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package controller
+
+import (
+       "context"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/require"
+       corev1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+       "k8s.io/apimachinery/pkg/types"
+       clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+       "sigs.k8s.io/controller-runtime/pkg/client/fake"
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+       "sigs.k8s.io/gateway-api/apis/v1beta1"
+
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+const (
+       consumerNS = "team-a"
+       secretNS   = "team-b"
+)
+
+func buildConsumerReconciler(t *testing.T, objs ...runtime.Object) 
*ConsumerReconciler {
+       t.Helper()
+
+       scheme := runtime.NewScheme()
+       require.NoError(t, clientgoscheme.AddToScheme(scheme))
+       require.NoError(t, v1alpha1.AddToScheme(scheme))
+       require.NoError(t, gatewayv1.Install(scheme))
+       require.NoError(t, v1beta1.Install(scheme))
+
+       cli := 
fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build()
+       return &ConsumerReconciler{Client: cli, Log: logr.Discard()}
+}
+
+func crossNamespaceConsumer() *v1alpha1.Consumer {
+       target := secretNS
+       return &v1alpha1.Consumer{
+               ObjectMeta: metav1.ObjectMeta{Name: "attacker", Namespace: 
consumerNS},
+               Spec: v1alpha1.ConsumerSpec{
+                       Credentials: []v1alpha1.Credential{{
+                               Name:      "cred",
+                               Type:      "key-auth",
+                               SecretRef: &v1alpha1.SecretReference{Name: 
"victim-secret", Namespace: &target},
+                       }},
+               },
+       }
+}
+
+func victimSecret() *corev1.Secret {
+       return &corev1.Secret{
+               ObjectMeta: metav1.ObjectMeta{Name: "victim-secret", Namespace: 
secretNS},
+               Data:       map[string][]byte{"key": []byte("victim-key")},
+       }
+}
+
+func secretGrant() *v1beta1.ReferenceGrant {
+       return &v1beta1.ReferenceGrant{
+               ObjectMeta: metav1.ObjectMeta{Name: "allow-consumer", 
Namespace: secretNS},
+               Spec: v1beta1.ReferenceGrantSpec{
+                       From: []v1beta1.ReferenceGrantFrom{{
+                               Group:     
v1beta1.Group(v1alpha1.GroupVersion.Group),
+                               Kind:      "Consumer",
+                               Namespace: consumerNS,
+                       }},
+                       To: []v1beta1.ReferenceGrantTo{{
+                               Group: "",
+                               Kind:  "Secret",
+                       }},
+               },
+       }
+}
+
+// Without a ReferenceGrant the cross-namespace secret must not be bound.
+func TestProcessSpec_CrossNamespaceSecretRef_DeniedWithoutGrant(t *testing.T) {
+       SetEnableReferenceGrant(true)
+       defer SetEnableReferenceGrant(false)
+
+       r := buildConsumerReconciler(t, victimSecret())
+       consumer := crossNamespaceConsumer()
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+
+       err := r.processSpec(context.Background(), tctx, consumer)
+       require.Error(t, err)
+       require.Empty(t, tctx.Secrets, "foreign secret must not be loaded 
without a ReferenceGrant")
+}
+
+// A matching ReferenceGrant permits the cross-namespace secret.
+func TestProcessSpec_CrossNamespaceSecretRef_AllowedWithGrant(t *testing.T) {
+       SetEnableReferenceGrant(true)
+       defer SetEnableReferenceGrant(false)
+
+       r := buildConsumerReconciler(t, victimSecret(), secretGrant())
+       consumer := crossNamespaceConsumer()
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+
+       err := r.processSpec(context.Background(), tctx, consumer)
+       require.NoError(t, err)
+       require.Contains(t, tctx.Secrets, types.NamespacedName{Namespace: 
secretNS, Name: "victim-secret"})
+}
+
+// Same-namespace SecretRef needs no grant.
+func TestProcessSpec_SameNamespaceSecretRef_Allowed(t *testing.T) {
+       SetEnableReferenceGrant(true)
+       defer SetEnableReferenceGrant(false)
+
+       secret := &corev1.Secret{
+               ObjectMeta: metav1.ObjectMeta{Name: "local-secret", Namespace: 
consumerNS},
+               Data:       map[string][]byte{"key": []byte("local-key")},
+       }
+       r := buildConsumerReconciler(t, secret)
+       consumer := &v1alpha1.Consumer{
+               ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: 
consumerNS},
+               Spec: v1alpha1.ConsumerSpec{
+                       Credentials: []v1alpha1.Credential{{
+                               Name:      "cred",
+                               Type:      "key-auth",
+                               SecretRef: &v1alpha1.SecretReference{Name: 
"local-secret"},
+                       }},
+               },
+       }
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+
+       err := r.processSpec(context.Background(), tctx, consumer)
+       require.NoError(t, err)
+       require.Contains(t, tctx.Secrets, types.NamespacedName{Namespace: 
consumerNS, Name: "local-secret"})
+}
diff --git a/internal/webhook/v1/consumer_webhook_test.go 
b/internal/webhook/v1/consumer_webhook_test.go
index 4dc32b84..f6a2b810 100644
--- a/internal/webhook/v1/consumer_webhook_test.go
+++ b/internal/webhook/v1/consumer_webhook_test.go
@@ -27,12 +27,40 @@ import (
        clientgoscheme "k8s.io/client-go/kubernetes/scheme"
        "sigs.k8s.io/controller-runtime/pkg/client/fake"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+       "sigs.k8s.io/gateway-api/apis/v1beta1"
 
        apisixv1alpha1 
"github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       "github.com/apache/apisix-ingress-controller/internal/controller"
        "github.com/apache/apisix-ingress-controller/internal/controller/config"
        
"github.com/apache/apisix-ingress-controller/internal/controller/indexer"
 )
 
+// authNS is a foreign namespace used for cross-namespace secretRef tests.
+const authNS = "auth"
+
+// enableReferenceGrant turns on the cross-namespace ReferenceGrant gate for 
the
+// duration of a test and resets it afterwards.
+func enableReferenceGrant(t *testing.T) {
+       t.Helper()
+       controller.SetEnableReferenceGrant(true)
+       t.Cleanup(func() { controller.SetEnableReferenceGrant(false) })
+}
+
+// consumerToSecretGrant permits Consumers in fromNS to reference Secrets in 
grantNS.
+func consumerToSecretGrant(grantNS, fromNS string) *v1beta1.ReferenceGrant {
+       return &v1beta1.ReferenceGrant{
+               ObjectMeta: metav1.ObjectMeta{Name: "allow-consumer", 
Namespace: grantNS},
+               Spec: v1beta1.ReferenceGrantSpec{
+                       From: []v1beta1.ReferenceGrantFrom{{
+                               Group:     
v1beta1.Group(apisixv1alpha1.GroupVersion.Group),
+                               Kind:      "Consumer",
+                               Namespace: v1beta1.Namespace(fromNS),
+                       }},
+                       To: []v1beta1.ReferenceGrantTo{{Group: "", Kind: 
"Secret"}},
+               },
+       }
+}
+
 func buildConsumerValidator(t *testing.T, objects ...runtime.Object) 
*ConsumerCustomValidator {
        t.Helper()
 
@@ -40,6 +68,7 @@ func buildConsumerValidator(t *testing.T, objects 
...runtime.Object) *ConsumerCu
        require.NoError(t, clientgoscheme.AddToScheme(scheme))
        require.NoError(t, apisixv1alpha1.AddToScheme(scheme))
        require.NoError(t, gatewayv1.Install(scheme))
+       require.NoError(t, v1beta1.Install(scheme))
 
        managed := []runtime.Object{
                &gatewayv1.GatewayClass{
@@ -90,7 +119,7 @@ func TestConsumerValidator_MissingSecretDefaultNamespace(t 
*testing.T) {
 }
 
 func TestConsumerValidator_MissingSecretCustomNamespace(t *testing.T) {
-       ns := "auth"
+       ns := authNS
        consumer := &apisixv1alpha1.Consumer{
                ObjectMeta: metav1.ObjectMeta{
                        Name:      "demo",
@@ -108,7 +137,8 @@ func TestConsumerValidator_MissingSecretCustomNamespace(t 
*testing.T) {
                },
        }
 
-       validator := buildConsumerValidator(t)
+       enableReferenceGrant(t)
+       validator := buildConsumerValidator(t, consumerToSecretGrant(authNS, 
"default"))
 
        warnings, err := validator.ValidateCreate(context.Background(), 
consumer)
        require.NoError(t, err)
@@ -116,8 +146,37 @@ func TestConsumerValidator_MissingSecretCustomNamespace(t 
*testing.T) {
        require.Contains(t, warnings[0], "Referenced Secret 'auth/jwt-secret' 
not found")
 }
 
+// A cross-namespace secretRef without a permitting ReferenceGrant is rejected 
at admission.
+func TestConsumerValidator_CrossNamespaceSecretRefDeniedWithoutGrant(t 
*testing.T) {
+       enableReferenceGrant(t)
+       ns := authNS
+       consumer := &apisixv1alpha1.Consumer{
+               ObjectMeta: metav1.ObjectMeta{
+                       Name:      "demo",
+                       Namespace: "default",
+               },
+               Spec: apisixv1alpha1.ConsumerSpec{
+                       GatewayRef: apisixv1alpha1.GatewayRef{Name: 
"test-gateway"},
+                       Credentials: []apisixv1alpha1.Credential{{
+                               Type: "jwt-auth",
+                               SecretRef: &apisixv1alpha1.SecretReference{
+                                       Name:      "jwt-secret",
+                                       Namespace: &ns,
+                               },
+                       }},
+               },
+       }
+
+       secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: 
"jwt-secret", Namespace: authNS}}
+       validator := buildConsumerValidator(t, secret)
+
+       _, err := validator.ValidateCreate(context.Background(), consumer)
+       require.Error(t, err)
+       require.Contains(t, err.Error(), "not permitted by any ReferenceGrant")
+}
+
 func TestConsumerValidator_NoWarnings(t *testing.T) {
-       ns := "auth"
+       ns := authNS
        consumer := &apisixv1alpha1.Consumer{
                ObjectMeta: metav1.ObjectMeta{
                        Name:      "demo",
@@ -140,9 +199,11 @@ func TestConsumerValidator_NoWarnings(t *testing.T) {
                },
        }
 
+       enableReferenceGrant(t)
        objs := []runtime.Object{
-               &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: 
"jwt-secret", Namespace: "auth"}},
+               &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: 
"jwt-secret", Namespace: authNS}},
                &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: 
"key-secret", Namespace: "default"}},
+               consumerToSecretGrant(authNS, "default"),
        }
 
        validator := buildConsumerValidator(t, objs...)

Reply via email to