diff --git a/internal/controller/consumer_controller.go b/internal/controller/consumer_controller.go index 2c0a4eb0..3ed74379 100644 --- a/internal/controller/consumer_controller.go +++ b/internal/controller/consumer_controller.go @@ -35,7 +35,6 @@ 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" @@ -255,21 +254,8 @@ func (r *ConsumerReconciler) processSpec(ctx context.Context, tctx *provider.Tra 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 { + secretNN := types.NamespacedName{Namespace: ns, Name: credential.SecretRef.Name} + if !CheckConsumerSecretRef(ctx, r.Client, consumer.GetNamespace(), secretNN) { 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", diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 04400609..84eac81f 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -1345,6 +1345,25 @@ func checkReferenceGrant(ctx context.Context, cli client.Client, obj v1beta1.Ref return false } +// CheckConsumerSecretRef reports whether a Consumer in fromNamespace may reference +// the Secret at secretNN, honoring ReferenceGrant for cross-namespace references. +func CheckConsumerSecretRef(ctx context.Context, cli client.Client, fromNamespace string, secretNN k8stypes.NamespacedName) bool { + secretNS := secretNN.Namespace + return checkReferenceGrant(ctx, cli, + v1beta1.ReferenceGrantFrom{ + Group: v1beta1.Group(v1alpha1.GroupVersion.Group), + Kind: types.KindConsumer, + Namespace: v1beta1.Namespace(fromNamespace), + }, + gatewayv1.ObjectReference{ + Group: corev1.GroupName, + Kind: types.KindSecret, + Name: gatewayv1.ObjectName(secretNN.Name), + Namespace: (*gatewayv1.Namespace)(&secretNS), + }, + ) +} + func ListRequests( ctx context.Context, c client.Client, diff --git a/internal/webhook/v1/consumer_webhook.go b/internal/webhook/v1/consumer_webhook.go index 57767520..db390cf9 100644 --- a/internal/webhook/v1/consumer_webhook.go +++ b/internal/webhook/v1/consumer_webhook.go @@ -134,6 +134,14 @@ func (v *ConsumerCustomValidator) collectWarnings(ctx context.Context, consumer } visited[nn] = struct{}{} + // Don't probe cross-namespace Secrets that no ReferenceGrant permits: the + // found/not-found warning difference would leak Secret existence across + // namespaces. Emit a uniform message and skip the lookup. + if namespace != defaultNamespace && !controller.CheckConsumerSecretRef(ctx, v.Client, defaultNamespace, nn) { + warnings = append(warnings, fmt.Sprintf("Referenced Secret '%s/%s' is not accessible from this Consumer without a ReferenceGrant", nn.Namespace, nn.Name)) + continue + } + warnings = append(warnings, v.checker.Secret(ctx, reference.SecretRef{ Object: consumer, NamespacedName: nn, diff --git a/internal/webhook/v1/consumer_webhook_test.go b/internal/webhook/v1/consumer_webhook_test.go index f6a2b810..a80163c7 100644 --- a/internal/webhook/v1/consumer_webhook_test.go +++ b/internal/webhook/v1/consumer_webhook_test.go @@ -175,6 +175,50 @@ func TestConsumerValidator_CrossNamespaceSecretRefDeniedWithoutGrant(t *testing. require.Contains(t, err.Error(), "not permitted by any ReferenceGrant") } +// The rejection above must not double as an existence oracle: a cross-namespace ref +// that no ReferenceGrant permits produces the same response whether or not the +// Secret exists. +func TestConsumerValidator_CrossNamespaceSecretOracleSuppressed(t *testing.T) { + enableReferenceGrant(t) + ns := authNS + newConsumer := func() *apisixv1alpha1.Consumer { + return &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 present in the foreign namespace, no ReferenceGrant permitting the ref. + present := buildConsumerValidator(t, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "jwt-secret", Namespace: authNS}, + }) + presentWarnings, presentErr := present.ValidateCreate(context.Background(), newConsumer()) + require.Error(t, presentErr) + + // Same request, Secret absent. + absent := buildConsumerValidator(t) + absentWarnings, absentErr := absent.ValidateCreate(context.Background(), newConsumer()) + require.Error(t, absentErr) + + // Identical response either way: no existence oracle. + require.Equal(t, absentWarnings, presentWarnings) + require.Equal(t, absentErr.Error(), presentErr.Error()) + require.Len(t, presentWarnings, 1) + require.Contains(t, presentWarnings[0], "Referenced Secret 'auth/jwt-secret' is not accessible from this Consumer without a ReferenceGrant") +} + func TestConsumerValidator_NoWarnings(t *testing.T) { ns := authNS consumer := &apisixv1alpha1.Consumer{