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...)