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

AlinsRan 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 8610842f feat: support secretRef in v1alpha1 plugin configuration 
(#2855)
8610842f is described below

commit 8610842f6f122ccdbd711338596a8d79bcc029fc
Author: AlinsRan <[email protected]>
AuthorDate: Wed Sep 9 19:44:29 2026 +0800

    feat: support secretRef in v1alpha1 plugin configuration (#2855)
---
 api/v1alpha1/pluginconfig_types.go                 |   8 +
 api/v1alpha1/zz_generated.deepcopy.go              |   6 +
 config/crd/bases/apisix.apache.org_consumers.yaml  |  19 +++
 .../bases/apisix.apache.org_l4routepolicies.yaml   |  19 +++
 .../crd/bases/apisix.apache.org_pluginconfigs.yaml |  19 +++
 docs/en/latest/reference/api-reference.md          |   1 +
 docs/en/latest/reference/example.md                |  79 +++++++++
 internal/adc/translator/consumer.go                |  17 +-
 internal/adc/translator/grpcroute.go               |  11 +-
 internal/adc/translator/httproute.go               |  54 ++++---
 internal/adc/translator/l4routepolicy_test.go      |  10 +-
 internal/adc/translator/plugin.go                  |  57 +++++++
 internal/adc/translator/plugin_test.go             | 177 +++++++++++++++++++++
 internal/adc/translator/policies.go                |  22 +--
 internal/adc/translator/tcproute.go                |   2 +-
 internal/adc/translator/tlsroute.go                |   2 +-
 internal/controller/consumer_controller.go         |   2 +-
 internal/controller/grpcroute_controller.go        |  36 ++++-
 internal/controller/httproute_controller.go        |  36 ++++-
 internal/controller/indexer/indexer.go             |  36 +++++
 internal/controller/policies.go                    |  20 ++-
 internal/controller/tcproute_controller.go         |  23 ++-
 internal/controller/tlsroute_controller.go         |  23 ++-
 internal/controller/udproute_controller.go         |  23 ++-
 internal/controller/utils.go                       |  39 +++++
 test/e2e/gatewayapi/httproute.go                   | 101 ++++++++++++
 test/e2e/gatewayapi/tcproute.go                    |  40 +++++
 27 files changed, 812 insertions(+), 70 deletions(-)

diff --git a/api/v1alpha1/pluginconfig_types.go 
b/api/v1alpha1/pluginconfig_types.go
index 600cb9a4..a341b887 100644
--- a/api/v1alpha1/pluginconfig_types.go
+++ b/api/v1alpha1/pluginconfig_types.go
@@ -18,6 +18,7 @@
 package v1alpha1
 
 import (
+       corev1 "k8s.io/api/core/v1"
        apiextensionsv1 
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
@@ -57,6 +58,13 @@ type Plugin struct {
        Name string `json:"name" yaml:"name"`
        // Config is plugin configuration details.
        Config apiextensionsv1.JSON `json:"config,omitempty" 
yaml:"config,omitempty"`
+       // SecretRef references a Secret in the same namespace holding 
sensitive parts of
+       // the plugin configuration, so they do not have to be written in 
`config`.
+       // Each Secret key is a dot separated path into the configuration, so 
the key
+       // `session.secret` sets the `secret` field of the `session` object. 
Values are
+       // merged as strings and take precedence over the same path in `config`.
+       // +optional
+       SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty" 
yaml:"secretRef,omitempty"`
 }
 
 func init() {
diff --git a/api/v1alpha1/zz_generated.deepcopy.go 
b/api/v1alpha1/zz_generated.deepcopy.go
index 4c112170..8b9c5438 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -20,6 +20,7 @@
 package v1alpha1
 
 import (
+       corev1 "k8s.io/api/core/v1"
        "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        runtime "k8s.io/apimachinery/pkg/runtime"
@@ -908,6 +909,11 @@ func (in *PassiveHealthCheckUnhealthy) DeepCopy() 
*PassiveHealthCheckUnhealthy {
 func (in *Plugin) DeepCopyInto(out *Plugin) {
        *out = *in
        in.Config.DeepCopyInto(&out.Config)
+       if in.SecretRef != nil {
+               in, out := &in.SecretRef, &out.SecretRef
+               *out = new(corev1.LocalObjectReference)
+               **out = **in
+       }
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new Plugin.
diff --git a/config/crd/bases/apisix.apache.org_consumers.yaml 
b/config/crd/bases/apisix.apache.org_consumers.yaml
index 187bc113..c1851e84 100644
--- a/config/crd/bases/apisix.apache.org_consumers.yaml
+++ b/config/crd/bases/apisix.apache.org_consumers.yaml
@@ -111,6 +111,25 @@ spec:
                     name:
                       description: Name is the name of the plugin.
                       type: string
+                    secretRef:
+                      description: |-
+                        SecretRef references a Secret in the same namespace 
holding sensitive parts of
+                        the plugin configuration, so they do not have to be 
written in `config`.
+                        Each Secret key is a dot separated path into the 
configuration, so the key
+                        `session.secret` sets the `secret` field of the 
`session` object. Values are
+                        merged as strings and take precedence over the same 
path in `config`.
+                      properties:
+                        name:
+                          default: ""
+                          description: |-
+                            Name of the referent.
+                            This field is effectively required, but due to 
backwards compatibility is
+                            allowed to be empty. Instances of this type with 
an empty value here are
+                            almost certainly wrong.
+                            More info: 
https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                          type: string
+                      type: object
+                      x-kubernetes-map-type: atomic
                   required:
                   - name
                   type: object
diff --git a/config/crd/bases/apisix.apache.org_l4routepolicies.yaml 
b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
index 752086e0..b9bda392 100644
--- a/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
+++ b/config/crd/bases/apisix.apache.org_l4routepolicies.yaml
@@ -54,6 +54,25 @@ spec:
                     name:
                       description: Name is the name of the plugin.
                       type: string
+                    secretRef:
+                      description: |-
+                        SecretRef references a Secret in the same namespace 
holding sensitive parts of
+                        the plugin configuration, so they do not have to be 
written in `config`.
+                        Each Secret key is a dot separated path into the 
configuration, so the key
+                        `session.secret` sets the `secret` field of the 
`session` object. Values are
+                        merged as strings and take precedence over the same 
path in `config`.
+                      properties:
+                        name:
+                          default: ""
+                          description: |-
+                            Name of the referent.
+                            This field is effectively required, but due to 
backwards compatibility is
+                            allowed to be empty. Instances of this type with 
an empty value here are
+                            almost certainly wrong.
+                            More info: 
https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                          type: string
+                      type: object
+                      x-kubernetes-map-type: atomic
                   required:
                   - name
                   type: object
diff --git a/config/crd/bases/apisix.apache.org_pluginconfigs.yaml 
b/config/crd/bases/apisix.apache.org_pluginconfigs.yaml
index 0891f5e8..f0bee28e 100644
--- a/config/crd/bases/apisix.apache.org_pluginconfigs.yaml
+++ b/config/crd/bases/apisix.apache.org_pluginconfigs.yaml
@@ -52,6 +52,25 @@ spec:
                     name:
                       description: Name is the name of the plugin.
                       type: string
+                    secretRef:
+                      description: |-
+                        SecretRef references a Secret in the same namespace 
holding sensitive parts of
+                        the plugin configuration, so they do not have to be 
written in `config`.
+                        Each Secret key is a dot separated path into the 
configuration, so the key
+                        `session.secret` sets the `secret` field of the 
`session` object. Values are
+                        merged as strings and take precedence over the same 
path in `config`.
+                      properties:
+                        name:
+                          default: ""
+                          description: |-
+                            Name of the referent.
+                            This field is effectively required, but due to 
backwards compatibility is
+                            allowed to be empty. Instances of this type with 
an empty value here are
+                            almost certainly wrong.
+                            More info: 
https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                          type: string
+                      type: object
+                      x-kubernetes-map-type: atomic
                   required:
                   - name
                   type: object
diff --git a/docs/en/latest/reference/api-reference.md 
b/docs/en/latest/reference/api-reference.md
index 09b6fd83..84cf404b 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -567,6 +567,7 @@ _Appears in:_
 | --- | --- |
 | `name` _string_ | Name is the name of the plugin. |
 | `config` 
_[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#json-v1-apiextensions-k8s-io)_
 | Config is plugin configuration details. |
+| `secretRef` 
_[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#localobjectreference-v1-core)_
 | SecretRef references a Secret in the same namespace holding sensitive parts 
of the plugin configuration, so they do not have to be written in `config`. 
Each Secret key is a dot separated path into the configuration, so the key 
`session.secret` sets the `secret` field of the `session` object. Values are 
merged as strings and take precedence over the s [...]
 
 
 _Appears in:_
diff --git a/docs/en/latest/reference/example.md 
b/docs/en/latest/reference/example.md
index 1656521d..18be93c0 100644
--- a/docs/en/latest/reference/example.md
+++ b/docs/en/latest/reference/example.md
@@ -1118,6 +1118,85 @@ spec:
 
 </Tabs>
 
+## Configure Plugin Secrets
+
+To keep sensitive plugin configuration in a Secret instead of in the resource, 
reference the
+Secret from the plugin. Each Secret key is a dot separated path into the 
plugin configuration,
+so `session.secret` sets the `secret` field of the `session` object. Values 
are merged as
+strings and take precedence over the same path in `config`, so keep numeric 
and boolean fields
+in `config`. The Secret must be in the same namespace as the resource that 
references it.
+
+<Tabs
+groupId="k8s-api"
+defaultValue="gateway"
+values={[
+{label: 'Gateway API', value: 'gateway'},
+{label: 'APISIX CRD', value: 'apisix-crd'}
+]}>
+
+<TabItem value="gateway">
+
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  namespace: ingress-apisix
+  name: oidc-credentials
+stringData:
+  client_id: "my-client"
+  client_secret: "s3cr3t"
+  session.secret: "8f2a6c1d9e4b7a30"
+---
+apiVersion: apisix.apache.org/v1alpha1
+kind: PluginConfig
+metadata:
+  namespace: ingress-apisix
+  name: oidc
+spec:
+  plugins:
+  - name: openid-connect
+    secretRef:
+      name: oidc-credentials
+    config:
+      discovery: https://idp.example.com/.well-known/openid-configuration
+      scope: openid profile
+```
+
+</TabItem>
+
+<TabItem value="apisix-crd">
+
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  namespace: ingress-apisix
+  name: oidc-credentials
+stringData:
+  client_id: "my-client"
+  client_secret: "s3cr3t"
+  session.secret: "8f2a6c1d9e4b7a30"
+---
+apiVersion: apisix.apache.org/v2
+kind: ApisixPluginConfig
+metadata:
+  namespace: ingress-apisix
+  name: oidc
+spec:
+  ingressClassName: apisix
+  plugins:
+  - name: openid-connect
+    enable: true
+    secretRef: oidc-credentials
+    config:
+      discovery: https://idp.example.com/.well-known/openid-configuration
+      scope: openid profile
+```
+
+</TabItem>
+
+</Tabs>
+
 ## Configure Gateway Access Information
 
 These configurations allow Ingress Controller users to access the gateway.
diff --git a/internal/adc/translator/consumer.go 
b/internal/adc/translator/consumer.go
index 0d73b483..d7e638be 100644
--- a/internal/adc/translator/consumer.go
+++ b/internal/adc/translator/consumer.go
@@ -77,17 +77,14 @@ func (t *Translator) TranslateConsumerV1alpha1(tctx 
*provider.TranslateContext,
        consumer.Labels = label.GenLabelWithObjectLabels(consumerV)
        plugins := adctypes.Plugins{}
        for _, plugin := range consumerV.Spec.Plugins {
-               pluginName := plugin.Name
-               pluginConfig := make(map[string]any)
-               if len(plugin.Config.Raw) > 0 {
-                       if err := json.Unmarshal(plugin.Config.Raw, 
&pluginConfig); err != nil {
-                               t.Log.Error(err, "failed to unmarshal plugin 
config",
-                                       "consumer", 
consumerV.Namespace+"/"+consumerV.Name,
-                                       "plugin", plugin.Name)
-                               continue
-                       }
+               pluginConfig, err := renderPluginConfig(plugin, 
consumerV.Namespace, tctx.Secrets)
+               if err != nil {
+                       t.Log.Error(err, "failed to render plugin config",
+                               "consumer", 
consumerV.Namespace+"/"+consumerV.Name,
+                               "plugin", plugin.Name)
+                       continue
                }
-               plugins[pluginName] = pluginConfig
+               plugins[plugin.Name] = pluginConfig
        }
        consumer.Plugins = plugins
        result.Consumers = append(result.Consumers, consumer)
diff --git a/internal/adc/translator/grpcroute.go 
b/internal/adc/translator/grpcroute.go
index 53cd591a..89f31355 100644
--- a/internal/adc/translator/grpcroute.go
+++ b/internal/adc/translator/grpcroute.go
@@ -39,7 +39,7 @@ func (t *Translator) fillPluginsFromGRPCRouteFilters(
        namespace string,
        filters []gatewayv1.GRPCRouteFilter,
        tctx *provider.TranslateContext,
-) {
+) error {
        for _, filter := range filters {
                switch filter.Type {
                case gatewayv1.GRPCRouteFilterRequestHeaderModifier:
@@ -49,9 +49,12 @@ func (t *Translator) fillPluginsFromGRPCRouteFilters(
                case gatewayv1.GRPCRouteFilterResponseHeaderModifier:
                        t.fillPluginFromHTTPResponseHeaderFilter(plugins, 
filter.ResponseHeaderModifier)
                case gatewayv1.GRPCRouteFilterExtensionRef:
-                       t.fillPluginFromExtensionRef(plugins, namespace, 
filter.ExtensionRef, tctx)
+                       if err := t.fillPluginFromExtensionRef(plugins, 
namespace, filter.ExtensionRef, tctx); err != nil {
+                               return err
+                       }
                }
        }
+       return nil
 }
 
 func calculateGRPCRoutePriority(match *gatewayv1.GRPCRouteMatch, ruleIndex 
int, hosts []string) uint64 {
@@ -287,7 +290,9 @@ func (t *Translator) TranslateGRPCRoute(tctx 
*provider.TranslateContext, grpcRou
                        }
                }
 
-               t.fillPluginsFromGRPCRouteFilters(service.Plugins, 
grpcRoute.GetNamespace(), rule.Filters, tctx)
+               if err := t.fillPluginsFromGRPCRouteFilters(service.Plugins, 
grpcRoute.GetNamespace(), rule.Filters, tctx); err != nil {
+                       return nil, err
+               }
 
                matches := rule.Matches
                if len(matches) == 0 {
diff --git a/internal/adc/translator/httproute.go 
b/internal/adc/translator/httproute.go
index 82f48256..d933a958 100644
--- a/internal/adc/translator/httproute.go
+++ b/internal/adc/translator/httproute.go
@@ -46,7 +46,7 @@ func (t *Translator) fillPluginsFromHTTPRouteFilters(
        filters []gatewayv1.HTTPRouteFilter,
        matches []gatewayv1.HTTPRouteMatch,
        tctx *provider.TranslateContext,
-) {
+) error {
        for _, filter := range filters {
                switch filter.Type {
                case gatewayv1.HTTPRouteFilterRequestHeaderModifier:
@@ -60,38 +60,42 @@ func (t *Translator) fillPluginsFromHTTPRouteFilters(
                case gatewayv1.HTTPRouteFilterResponseHeaderModifier:
                        t.fillPluginFromHTTPResponseHeaderFilter(plugins, 
filter.ResponseHeaderModifier)
                case gatewayv1.HTTPRouteFilterExtensionRef:
-                       t.fillPluginFromExtensionRef(plugins, namespace, 
filter.ExtensionRef, tctx)
+                       if err := t.fillPluginFromExtensionRef(plugins, 
namespace, filter.ExtensionRef, tctx); err != nil {
+                               return err
+                       }
                case gatewayv1.HTTPRouteFilterCORS:
                        t.fillPluginFromHTTPCORSFilter(plugins, filter.CORS)
                }
        }
+       return nil
 }
 
-func (t *Translator) fillPluginFromExtensionRef(plugins adctypes.Plugins, 
namespace string, extensionRef *gatewayv1.LocalObjectReference, tctx 
*provider.TranslateContext) {
+func (t *Translator) fillPluginFromExtensionRef(plugins adctypes.Plugins, 
namespace string, extensionRef *gatewayv1.LocalObjectReference, tctx 
*provider.TranslateContext) error {
        if extensionRef == nil {
-               return
+               return nil
        }
-       if extensionRef.Kind == internaltypes.KindPluginConfig {
-               pluginconfig := tctx.PluginConfigs[types.NamespacedName{
-                       Namespace: namespace,
-                       Name:      string(extensionRef.Name),
-               }]
-               if pluginconfig == nil {
-                       return
-               }
-               for _, plugin := range pluginconfig.Spec.Plugins {
-                       pluginName := plugin.Name
-                       pluginconfig := make(map[string]any)
-                       if len(plugin.Config.Raw) > 0 {
-                               if err := json.Unmarshal(plugin.Config.Raw, 
&pluginconfig); err != nil {
-                                       t.Log.Error(err, "plugin config 
unmarshal failed", "plugin", plugin.Name)
-                                       continue
-                               }
-                       }
-                       plugins[pluginName] = pluginconfig
+       if extensionRef.Kind != internaltypes.KindPluginConfig {
+               return nil
+       }
+       pluginconfig := tctx.PluginConfigs[types.NamespacedName{
+               Namespace: namespace,
+               Name:      string(extensionRef.Name),
+       }]
+       if pluginconfig == nil {
+               return nil
+       }
+       names := make([]string, 0, len(pluginconfig.Spec.Plugins))
+       for _, plugin := range pluginconfig.Spec.Plugins {
+               config, err := renderPluginConfig(plugin, namespace, 
tctx.Secrets)
+               if err != nil {
+                       return err
                }
-               t.Log.V(1).Info("fill plugin from extension ref", "plugins", 
plugins)
+               plugins[plugin.Name] = config
+               names = append(names, plugin.Name)
        }
+       // The rendered configuration may hold Secret data, so log the plugin 
names only.
+       t.Log.V(1).Info("fill plugin from extension ref", "pluginConfig", 
string(extensionRef.Name), "plugins", names)
+       return nil
 }
 
 func (t *Translator) fillPluginFromURLRewriteFilter(plugins adctypes.Plugins, 
urlRewrite *gatewayv1.HTTPURLRewriteFilter, matches []gatewayv1.HTTPRouteMatch) 
{
@@ -706,7 +710,9 @@ func (t *Translator) TranslateHTTPRoute(tctx 
*provider.TranslateContext, httpRou
 
                enableWebsocket, _ := t.translateBackendsToUpstreams(tctx, 
rule, httpRoute, service)
 
-               t.fillPluginsFromHTTPRouteFilters(service.Plugins, 
httpRoute.GetNamespace(), rule.Filters, rule.Matches, tctx)
+               if err := t.fillPluginsFromHTTPRouteFilters(service.Plugins, 
httpRoute.GetNamespace(), rule.Filters, rule.Matches, tctx); err != nil {
+                       return nil, err
+               }
 
                matches := rule.Matches
                if len(matches) == 0 {
diff --git a/internal/adc/translator/l4routepolicy_test.go 
b/internal/adc/translator/l4routepolicy_test.go
index f172e534..289dab98 100644
--- a/internal/adc/translator/l4routepolicy_test.go
+++ b/internal/adc/translator/l4routepolicy_test.go
@@ -74,7 +74,7 @@ func TestAttachL4RoutePolicyPlugins_AttachesMatchingPolicy(t 
*testing.T) {
        }
 
        plugins := adctypes.Plugins{}
-       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins)
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins, nil)
 
        assert.Len(t, plugins, 2)
        assert.Contains(t, plugins, "limit-conn")
@@ -97,7 +97,7 @@ func TestAttachL4RoutePolicyPlugins_NoMatchOnKind(t 
*testing.T) {
 
        plugins := adctypes.Plugins{}
        // Looking for TCPRoute, but policy targets UDPRoute — should not match.
-       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-udp-route", 
"TCPRoute", plugins)
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-udp-route", 
"TCPRoute", plugins, nil)
 
        assert.Empty(t, plugins)
 }
@@ -115,7 +115,7 @@ func TestAttachL4RoutePolicyPlugins_NoMatchOnNamespace(t 
*testing.T) {
 
        plugins := adctypes.Plugins{}
        // Route is in "default" namespace, policy is in "other-ns" — should 
not match.
-       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins)
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins, nil)
 
        assert.Empty(t, plugins)
 }
@@ -130,7 +130,7 @@ func TestAttachL4RoutePolicyPlugins_EmptyPlugins(t 
*testing.T) {
        }
 
        plugins := adctypes.Plugins{}
-       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins)
+       tr.AttachL4RoutePolicyPlugins(policies, "default", "my-tcp-route", 
"TCPRoute", plugins, nil)
 
        assert.Empty(t, plugins)
 }
@@ -138,6 +138,6 @@ func TestAttachL4RoutePolicyPlugins_EmptyPlugins(t 
*testing.T) {
 func TestAttachL4RoutePolicyPlugins_EmptyPolicies(t *testing.T) {
        tr := NewTranslator(logr.Discard(), "")
        plugins := adctypes.Plugins{}
-       tr.AttachL4RoutePolicyPlugins(nil, "default", "my-tcp-route", 
"TCPRoute", plugins)
+       tr.AttachL4RoutePolicyPlugins(nil, "default", "my-tcp-route", 
"TCPRoute", plugins, nil)
        assert.Empty(t, plugins)
 }
diff --git a/internal/adc/translator/plugin.go 
b/internal/adc/translator/plugin.go
new file mode 100644
index 00000000..d16180fc
--- /dev/null
+++ b/internal/adc/translator/plugin.go
@@ -0,0 +1,57 @@
+// 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 translator
+
+import (
+       "encoding/json"
+       "fmt"
+
+       corev1 "k8s.io/api/core/v1"
+       "k8s.io/apimachinery/pkg/types"
+
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
+)
+
+// renderPluginConfig renders the configuration of an 
apisix.apache.org/v1alpha1 Plugin.
+// The data of the referenced Secret is merged over spec.config, with each 
Secret key
+// read as a dot separated path so that `session.secret` nests under `session`.
+func renderPluginConfig(plugin v1alpha1.Plugin, namespace string, secrets 
map[types.NamespacedName]*corev1.Secret) (map[string]any, error) {
+       config := make(map[string]any)
+       if len(plugin.Config.Raw) > 0 {
+               if err := json.Unmarshal(plugin.Config.Raw, &config); err != 
nil {
+                       return nil, fmt.Errorf("failed to unmarshal config of 
plugin %s: %w", plugin.Name, err)
+               }
+       }
+       // A literal `config: null` unmarshals to a nil map, which serializes 
back to
+       // null and is rejected by most APISIX plugins; normalize it to an 
empty object.
+       if config == nil {
+               config = make(map[string]any)
+       }
+       if plugin.SecretRef == nil || plugin.SecretRef.Name == "" {
+               return config, nil
+       }
+       secret, ok := secrets[types.NamespacedName{Namespace: namespace, Name: 
plugin.SecretRef.Name}]
+       if !ok || secret == nil {
+               return nil, fmt.Errorf("secret %s/%s referenced by plugin %s 
not found", namespace, plugin.SecretRef.Name, plugin.Name)
+       }
+       for key, value := range secret.Data {
+               pkgutils.InsertKeyInMap(key, string(value), config)
+       }
+       return config, nil
+}
diff --git a/internal/adc/translator/plugin_test.go 
b/internal/adc/translator/plugin_test.go
new file mode 100644
index 00000000..6881ea42
--- /dev/null
+++ b/internal/adc/translator/plugin_test.go
@@ -0,0 +1,177 @@
+// 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 translator
+
+import (
+       "context"
+       "strings"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/go-logr/logr/funcr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       corev1 "k8s.io/api/core/v1"
+       apiextensionsv1 
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/types"
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+       "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+       internaltypes 
"github.com/apache/apisix-ingress-controller/internal/types"
+)
+
+func TestRenderPluginConfig_NoSecretRef(t *testing.T) {
+       config, err := renderPluginConfig(v1alpha1.Plugin{
+               Name:   "response-rewrite",
+               Config: apiextensionsv1.JSON{Raw: []byte(`{"body":"hello"}`)},
+       }, "default", nil)
+       require.NoError(t, err)
+       assert.Equal(t, map[string]any{"body": "hello"}, config)
+}
+
+func TestRenderPluginConfig_NullConfigBecomesEmptyObject(t *testing.T) {
+       config, err := renderPluginConfig(v1alpha1.Plugin{
+               Name:   "prometheus",
+               Config: apiextensionsv1.JSON{Raw: []byte(`null`)},
+       }, "default", nil)
+       require.NoError(t, err)
+       assert.Equal(t, map[string]any{}, config)
+}
+
+func TestRenderPluginConfig_SecretKeysAreNestedAndWin(t *testing.T) {
+       secrets := map[types.NamespacedName]*corev1.Secret{
+               {Namespace: "default", Name: "oidc-credentials"}: {
+                       Data: map[string][]byte{
+                               "client_id":      []byte("my-client"),
+                               "client_secret":  []byte("s3cr3t"),
+                               "session.secret": []byte("8f2a"),
+                       },
+               },
+       }
+       config, err := renderPluginConfig(v1alpha1.Plugin{
+               Name:      "openid-connect",
+               Config:    apiextensionsv1.JSON{Raw: 
[]byte(`{"client_id":"placeholder","scope":"openid profile"}`)},
+               SecretRef: &corev1.LocalObjectReference{Name: 
"oidc-credentials"},
+       }, "default", secrets)
+       require.NoError(t, err)
+       assert.Equal(t, "my-client", config["client_id"])
+       assert.Equal(t, "s3cr3t", config["client_secret"])
+       assert.Equal(t, "openid profile", config["scope"])
+       assert.Equal(t, map[string]any{"secret": "8f2a"}, config["session"])
+}
+
+func TestRenderPluginConfig_MissingSecretIsAnError(t *testing.T) {
+       config, err := renderPluginConfig(v1alpha1.Plugin{
+               Name:      "openid-connect",
+               SecretRef: &corev1.LocalObjectReference{Name: 
"oidc-credentials"},
+       }, "default", nil)
+       assert.ErrorContains(t, err, "default/oidc-credentials")
+       assert.Nil(t, config)
+}
+
+func TestRenderPluginConfig_MalformedConfigIsAnError(t *testing.T) {
+       config, err := renderPluginConfig(v1alpha1.Plugin{
+               Name:   "ip-restriction",
+               Config: apiextensionsv1.JSON{Raw: []byte(`["10.0.0.0/8"]`)},
+       }, "default", nil)
+       assert.ErrorContains(t, err, "ip-restriction")
+       assert.Nil(t, config)
+}
+
+func TestFillPluginFromExtensionRef_ResolvesSecretRef(t *testing.T) {
+       translator := NewTranslator(logr.Discard(), "")
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+       tctx.PluginConfigs[types.NamespacedName{Namespace: "default", Name: 
"oidc"}] = &v1alpha1.PluginConfig{
+               ObjectMeta: metav1.ObjectMeta{Name: "oidc", Namespace: 
"default"},
+               Spec: v1alpha1.PluginConfigSpec{
+                       Plugins: []v1alpha1.Plugin{{
+                               Name:      "openid-connect",
+                               Config:    apiextensionsv1.JSON{Raw: 
[]byte(`{"scope":"openid profile"}`)},
+                               SecretRef: &corev1.LocalObjectReference{Name: 
"oidc-credentials"},
+                       }},
+               },
+       }
+       tctx.Secrets[types.NamespacedName{Namespace: "default", Name: 
"oidc-credentials"}] = &corev1.Secret{
+               Data: map[string][]byte{"client_secret": []byte("s3cr3t")},
+       }
+
+       plugins := adctypes.Plugins{}
+       require.NoError(t, translator.fillPluginFromExtensionRef(plugins, 
"default", &gatewayv1.LocalObjectReference{
+               Kind: internaltypes.KindPluginConfig,
+               Name: "oidc",
+       }, tctx))
+
+       assert.Equal(t, map[string]any{
+               "scope":         "openid profile",
+               "client_secret": "s3cr3t",
+       }, plugins["openid-connect"])
+}
+
+func TestFillPluginFromExtensionRef_MissingSecretFailsTranslation(t 
*testing.T) {
+       translator := NewTranslator(logr.Discard(), "")
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+       tctx.PluginConfigs[types.NamespacedName{Namespace: "default", Name: 
"oidc"}] = &v1alpha1.PluginConfig{
+               ObjectMeta: metav1.ObjectMeta{Name: "oidc", Namespace: 
"default"},
+               Spec: v1alpha1.PluginConfigSpec{
+                       Plugins: []v1alpha1.Plugin{{
+                               Name:      "openid-connect",
+                               SecretRef: &corev1.LocalObjectReference{Name: 
"oidc-credentials"},
+                       }},
+               },
+       }
+
+       // The route must not be programmed without the plugin its filter asks 
for.
+       err := translator.fillPluginFromExtensionRef(adctypes.Plugins{}, 
"default", &gatewayv1.LocalObjectReference{
+               Kind: internaltypes.KindPluginConfig,
+               Name: "oidc",
+       }, tctx)
+       assert.ErrorContains(t, err, "default/oidc-credentials")
+}
+
+func TestFillPluginFromExtensionRef_DoesNotLogSecretValues(t *testing.T) {
+       var logged strings.Builder
+       logger := funcr.New(func(prefix, args string) {
+               logged.WriteString(args)
+       }, funcr.Options{Verbosity: 10})
+
+       translator := NewTranslator(logger, "")
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+       tctx.PluginConfigs[types.NamespacedName{Namespace: "default", Name: 
"oidc"}] = &v1alpha1.PluginConfig{
+               ObjectMeta: metav1.ObjectMeta{Name: "oidc", Namespace: 
"default"},
+               Spec: v1alpha1.PluginConfigSpec{
+                       Plugins: []v1alpha1.Plugin{{
+                               Name:      "openid-connect",
+                               SecretRef: &corev1.LocalObjectReference{Name: 
"oidc-credentials"},
+                       }},
+               },
+       }
+       tctx.Secrets[types.NamespacedName{Namespace: "default", Name: 
"oidc-credentials"}] = &corev1.Secret{
+               Data: map[string][]byte{"client_secret": []byte("s3cr3t")},
+       }
+
+       require.NoError(t, 
translator.fillPluginFromExtensionRef(adctypes.Plugins{}, "default", 
&gatewayv1.LocalObjectReference{
+               Kind: internaltypes.KindPluginConfig,
+               Name: "oidc",
+       }, tctx))
+
+       assert.Contains(t, logged.String(), "openid-connect")
+       assert.NotContains(t, logged.String(), "s3cr3t")
+}
diff --git a/internal/adc/translator/policies.go 
b/internal/adc/translator/policies.go
index 003c6cc4..aad0ff3f 100644
--- a/internal/adc/translator/policies.go
+++ b/internal/adc/translator/policies.go
@@ -18,8 +18,6 @@
 package translator
 
 import (
-       "encoding/json"
-
        corev1 "k8s.io/api/core/v1"
        "k8s.io/apimachinery/pkg/types"
        "k8s.io/utils/ptr"
@@ -230,6 +228,7 @@ func (t *Translator) AttachL4RoutePolicyPlugins(
        policies map[types.NamespacedName]*v1alpha1.L4RoutePolicy,
        routeNamespace, routeName, routeKind string,
        plugins adctypes.Plugins,
+       secrets map[types.NamespacedName]*corev1.Secret,
 ) {
        if len(policies) == 0 {
                return
@@ -253,25 +252,18 @@ func (t *Translator) AttachL4RoutePolicyPlugins(
                        if ref.SectionName != nil && *ref.SectionName != "" {
                                continue
                        }
-                       t.mergeL4PolicyPlugins(policy, plugins)
+                       t.mergeL4PolicyPlugins(policy, plugins, secrets)
                        return
                }
        }
 }
 
-func (t *Translator) mergeL4PolicyPlugins(policy *v1alpha1.L4RoutePolicy, 
plugins adctypes.Plugins) {
+func (t *Translator) mergeL4PolicyPlugins(policy *v1alpha1.L4RoutePolicy, 
plugins adctypes.Plugins, secrets map[types.NamespacedName]*corev1.Secret) {
        for _, plugin := range policy.Spec.Plugins {
-               cfg := make(map[string]any)
-               if len(plugin.Config.Raw) > 0 {
-                       if err := json.Unmarshal(plugin.Config.Raw, &cfg); err 
!= nil {
-                               t.Log.Error(err, "failed to unmarshal 
L4RoutePolicy plugin config", "plugin", plugin.Name, "policy", policy.Name)
-                               continue
-                       }
-               }
-               // A literal `config: null` unmarshals to a nil map, which 
serializes back to
-               // null and is rejected by most APISIX plugins; normalize it to 
an empty object.
-               if cfg == nil {
-                       cfg = map[string]any{}
+               cfg, err := renderPluginConfig(plugin, policy.Namespace, 
secrets)
+               if err != nil {
+                       t.Log.Error(err, "failed to render L4RoutePolicy plugin 
config", "plugin", plugin.Name, "policy", policy.Name)
+                       continue
                }
                plugins[plugin.Name] = cfg
        }
diff --git a/internal/adc/translator/tcproute.go 
b/internal/adc/translator/tcproute.go
index 605b609e..7de4a67e 100644
--- a/internal/adc/translator/tcproute.go
+++ b/internal/adc/translator/tcproute.go
@@ -98,7 +98,7 @@ func (t *Translator) buildL4StreamRoutes(tctx 
*provider.TranslateContext, namesp
                // Attach L4RoutePolicy plugins at the stream_route level: the 
APISIX stream proxy
                // applies plugins from the stream_route, not from the service.
                streamRoute.Plugins = make(adctypes.Plugins)
-               t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, namespace, 
name, routeKind, streamRoute.Plugins)
+               t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, namespace, 
name, routeKind, streamRoute.Plugins, tctx.Secrets)
                streamRoutes = append(streamRoutes, streamRoute)
        }
        return streamRoutes
diff --git a/internal/adc/translator/tlsroute.go 
b/internal/adc/translator/tlsroute.go
index 413f4790..8d1fd0a6 100644
--- a/internal/adc/translator/tlsroute.go
+++ b/internal/adc/translator/tlsroute.go
@@ -154,7 +154,7 @@ func (t *Translator) TranslateTLSRoute(tctx 
*provider.TranslateContext, tlsRoute
                        // applies plugins from the stream_route, not from the 
service. With multiple SNIs
                        // each stream_route carries its own copy of the 
plugins.
                        streamRoute.Plugins = make(adctypes.Plugins)
-                       t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, 
tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", streamRoute.Plugins)
+                       t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, 
tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", streamRoute.Plugins, 
tctx.Secrets)
                        service.StreamRoutes = append(service.StreamRoutes, 
streamRoute)
                }
 
diff --git a/internal/controller/consumer_controller.go 
b/internal/controller/consumer_controller.go
index ece20ec1..f3fb80a9 100644
--- a/internal/controller/consumer_controller.go
+++ b/internal/controller/consumer_controller.go
@@ -290,7 +290,7 @@ func (r *ConsumerReconciler) processSpec(ctx 
context.Context, tctx *provider.Tra
                }] = &secret
 
        }
-       return nil
+       return loadPluginSecrets(ctx, r.Client, tctx, consumer.GetNamespace(), 
consumer.Spec.Plugins)
 }
 
 func (r *ConsumerReconciler) updateStatus(consumer *v1alpha1.Consumer, err 
error) {
diff --git a/internal/controller/grpcroute_controller.go 
b/internal/controller/grpcroute_controller.go
index bbc45265..df50f135 100644
--- a/internal/controller/grpcroute_controller.go
+++ b/internal/controller/grpcroute_controller.go
@@ -45,6 +45,7 @@ import (
        "github.com/apache/apisix-ingress-controller/internal/provider"
        "github.com/apache/apisix-ingress-controller/internal/types"
        "github.com/apache/apisix-ingress-controller/internal/utils"
+       pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
 )
 
 // GRPCRouteReconciler reconciles a GatewayClass object.
@@ -68,13 +69,23 @@ func (r *GRPCRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
 
        bdr := ctrl.NewControllerManagedBy(mgr).
                For(&gatewayv1.GRPCRoute{}).
-               WithEventFilter(predicate.GenerationChangedPredicate{}).
+               // A Secret carries no generation, so 
GenerationChangedPredicate would drop its
+               // updates and a plugin would keep the Secret data read at the 
last spec change.
+               WithEventFilter(
+                       predicate.Or(
+                               predicate.GenerationChangedPredicate{},
+                               
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
+                       ),
+               ).
                Watches(&discoveryv1.EndpointSlice{},
                        
handler.EnqueueRequestsFromMapFunc(r.listGRPCRoutesByServiceRef),
                ).
                Watches(&v1alpha1.PluginConfig{},
                        
handler.EnqueueRequestsFromMapFunc(r.listGRPCRoutesByExtensionRef),
                ).
+               Watches(&corev1.Secret{},
+                       
handler.EnqueueRequestsFromMapFunc(r.listGRPCRoutesForSecret),
+               ).
                Watches(&gatewayv1.Gateway{},
                        
handler.EnqueueRequestsFromMapFunc(r.listGRPCRoutesForGateway),
                        builder.WithPredicates(
@@ -505,6 +516,10 @@ func (r *GRPCRouteReconciler) processGRPCRoute(tctx 
*provider.TranslateContext,
                                        Namespace: grpcroute.GetNamespace(),
                                        Name:      
string(filter.ExtensionRef.Name),
                                }] = pluginconfig
+                               if err := loadPluginSecrets(tctx, r.Client, 
tctx, grpcroute.GetNamespace(), pluginconfig.Spec.Plugins); err != nil {
+                                       terror = err
+                                       continue
+                               }
                        }
                }
                for _, backend := range rule.BackendRefs {
@@ -602,3 +617,22 @@ func (r *GRPCRouteReconciler) 
listGRPCRoutesForReferenceGrant(ctx context.Contex
        }
        return requests
 }
+
+// listGRPCRoutesForSecret maps a Secret to the GRPCRoutes that reference, 
through a PluginConfig
+// extension filter, a plugin configured with that Secret.
+func (r *GRPCRouteReconciler) listGRPCRoutesForSecret(ctx context.Context, obj 
client.Object) []reconcile.Request {
+       secret, ok := obj.(*corev1.Secret)
+       if !ok {
+               r.Log.Error(fmt.Errorf("unexpected object type"), "failed to 
convert object to Secret")
+               return nil
+       }
+       var requests []reconcile.Request
+       for _, pcRef := range ListRequests(ctx, r.Client, r.Log, 
&v1alpha1.PluginConfigList{}, client.MatchingFields{
+               indexer.SecretIndexRef: 
indexer.GenIndexKey(secret.GetNamespace(), secret.GetName()),
+       }) {
+               requests = append(requests, ListRequests(ctx, r.Client, r.Log, 
&gatewayv1.GRPCRouteList{}, client.MatchingFields{
+                       indexer.ExtensionRef: 
indexer.GenIndexKey(pcRef.Namespace, pcRef.Name),
+               })...)
+       }
+       return pkgutils.DedupComparable(requests)
+}
diff --git a/internal/controller/httproute_controller.go 
b/internal/controller/httproute_controller.go
index 26b94683..285899b8 100644
--- a/internal/controller/httproute_controller.go
+++ b/internal/controller/httproute_controller.go
@@ -48,6 +48,7 @@ import (
        "github.com/apache/apisix-ingress-controller/internal/provider"
        "github.com/apache/apisix-ingress-controller/internal/types"
        "github.com/apache/apisix-ingress-controller/internal/utils"
+       pkgutils "github.com/apache/apisix-ingress-controller/pkg/utils"
 )
 
 // HTTPRouteReconciler reconciles a GatewayClass object.
@@ -71,13 +72,23 @@ func (r *HTTPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
 
        bdr := ctrl.NewControllerManagedBy(mgr).
                For(&gatewayv1.HTTPRoute{}).
-               WithEventFilter(predicate.GenerationChangedPredicate{}).
+               // A Secret carries no generation, so 
GenerationChangedPredicate would drop its
+               // updates and a plugin would keep the Secret data read at the 
last spec change.
+               WithEventFilter(
+                       predicate.Or(
+                               predicate.GenerationChangedPredicate{},
+                               
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
+                       ),
+               ).
                Watches(&discoveryv1.EndpointSlice{},
                        
handler.EnqueueRequestsFromMapFunc(r.listHTTPRoutesByServiceRef),
                ).
                Watches(&v1alpha1.PluginConfig{},
                        
handler.EnqueueRequestsFromMapFunc(r.listHTTPRoutesByExtensionRef),
                ).
+               Watches(&corev1.Secret{},
+                       
handler.EnqueueRequestsFromMapFunc(r.listHTTPRoutesForSecret),
+               ).
                Watches(&gatewayv1.Gateway{},
                        
handler.EnqueueRequestsFromMapFunc(r.listHTTPRoutesForGateway),
                        builder.WithPredicates(
@@ -590,6 +601,10 @@ func (r *HTTPRouteReconciler) processHTTPRoute(tctx 
*provider.TranslateContext,
                                        Namespace: httpRoute.GetNamespace(),
                                        Name:      
string(filter.ExtensionRef.Name),
                                }] = pluginconfig
+                               if err := loadPluginSecrets(tctx, r.Client, 
tctx, httpRoute.GetNamespace(), pluginconfig.Spec.Plugins); err != nil {
+                                       terror = err
+                                       continue
+                               }
                        }
                }
                for _, backend := range rule.BackendRefs {
@@ -719,3 +734,22 @@ func (r *HTTPRouteReconciler) 
listHTTPRoutesForReferenceGrant(ctx context.Contex
        }
        return requests
 }
+
+// listHTTPRoutesForSecret maps a Secret to the HTTPRoutes that reference, 
through a PluginConfig
+// extension filter, a plugin configured with that Secret.
+func (r *HTTPRouteReconciler) listHTTPRoutesForSecret(ctx context.Context, obj 
client.Object) []reconcile.Request {
+       secret, ok := obj.(*corev1.Secret)
+       if !ok {
+               r.Log.Error(fmt.Errorf("unexpected object type"), "failed to 
convert object to Secret")
+               return nil
+       }
+       var requests []reconcile.Request
+       for _, pcRef := range ListRequests(ctx, r.Client, r.Log, 
&v1alpha1.PluginConfigList{}, client.MatchingFields{
+               indexer.SecretIndexRef: 
indexer.GenIndexKey(secret.GetNamespace(), secret.GetName()),
+       }) {
+               requests = append(requests, ListRequests(ctx, r.Client, r.Log, 
&gatewayv1.HTTPRouteList{}, client.MatchingFields{
+                       indexer.ExtensionRef: 
indexer.GenIndexKey(pcRef.Namespace, pcRef.Name),
+               })...)
+       }
+       return pkgutils.DedupComparable(requests)
+}
diff --git a/internal/controller/indexer/indexer.go 
b/internal/controller/indexer/indexer.go
index 7c5eaaf7..716e44e2 100644
--- a/internal/controller/indexer/indexer.go
+++ b/internal/controller/indexer/indexer.go
@@ -63,6 +63,7 @@ func SetupAPIv1alpha1Indexer(mgr ctrl.Manager) error {
                &v1alpha1.Consumer{}:             setupConsumerIndexer,
                &v1alpha1.GatewayProxy{}:         setupGatewayProxyIndexer,
                &v1alpha1.L4RoutePolicy{}:        setupL4RoutePolicyIndexer,
+               &v1alpha1.PluginConfig{}:         setupPluginConfigIndexer,
        } {
                installed, err := utils.HasAPIResource(mgr, resource)
                if err != nil {
@@ -258,6 +259,7 @@ func ConsumerSecretIndexFunc(rawObj client.Object) []string 
{
                key := GenIndexKey(ns, credential.SecretRef.Name)
                secretKeys = append(secretKeys, key)
        }
+       secretKeys = append(secretKeys, 
PluginSecretIndexKeys(consumer.GetNamespace(), consumer.Spec.Plugins)...)
        return secretKeys
 }
 
@@ -518,9 +520,43 @@ func setupL4RoutePolicyIndexer(mgr ctrl.Manager) error {
        ); err != nil {
                return err
        }
+       if err := mgr.GetFieldIndexer().IndexField(
+               context.Background(),
+               &v1alpha1.L4RoutePolicy{},
+               SecretIndexRef,
+               func(obj client.Object) []string {
+                       return PluginSecretIndexKeys(obj.GetNamespace(), 
obj.(*v1alpha1.L4RoutePolicy).Spec.Plugins)
+               },
+       ); err != nil {
+               return err
+       }
        return nil
 }
 
+func setupPluginConfigIndexer(mgr ctrl.Manager) error {
+       return mgr.GetFieldIndexer().IndexField(
+               context.Background(),
+               &v1alpha1.PluginConfig{},
+               SecretIndexRef,
+               func(obj client.Object) []string {
+                       return PluginSecretIndexKeys(obj.GetNamespace(), 
obj.(*v1alpha1.PluginConfig).Spec.Plugins)
+               },
+       )
+}
+
+// PluginSecretIndexKeys returns the index keys of the Secrets referenced by
+// apisix.apache.org/v1alpha1 plugins. Such Secrets are always in the 
namespace of the
+// object that declares the plugins.
+func PluginSecretIndexKeys(namespace string, plugins []v1alpha1.Plugin) (keys 
[]string) {
+       for _, plugin := range plugins {
+               if plugin.SecretRef == nil || plugin.SecretRef.Name == "" {
+                       continue
+               }
+               keys = append(keys, GenIndexKey(namespace, 
plugin.SecretRef.Name))
+       }
+       return
+}
+
 func IngressClassIndexFunc(rawObj client.Object) []string {
        ingressClass := rawObj.(*networkingv1.IngressClass)
        if ingressClass.Spec.Controller == "" {
diff --git a/internal/controller/policies.go b/internal/controller/policies.go
index d2152a5d..1cfb5c7f 100644
--- a/internal/controller/policies.go
+++ b/internal/controller/policies.go
@@ -300,12 +300,28 @@ func ProcessL4RoutePolicy(
        })
 
        winner := list.Items[0].DeepCopy()
-       tctx.L4RoutePolicies[types.NamespacedName{Namespace: winner.Namespace, 
Name: winner.Name}] = winner
+       // A policy whose Secrets cannot be read is not attached at all, so a 
route is never
+       // programmed with a subset of the plugins the policy asks for.
+       secretErr := loadPluginSecrets(tctx, c, tctx, winner.Namespace, 
winner.Spec.Plugins)
+       if secretErr != nil {
+               log.Error(secretErr, "failed to load Secrets referenced by 
L4RoutePolicy plugins", "policy", types.NamespacedName{Namespace: 
winner.Namespace, Name: winner.Name})
+       } else {
+               tctx.L4RoutePolicies[types.NamespacedName{Namespace: 
winner.Namespace, Name: winner.Name}] = winner
+       }
 
        for i := range list.Items {
                policy := list.Items[i]
                var condition metav1.Condition
-               if i == 0 {
+               if i == 0 && secretErr != nil {
+                       condition = metav1.Condition{
+                               Type:               
string(gatewayv1.PolicyConditionAccepted),
+                               Status:             metav1.ConditionFalse,
+                               ObservedGeneration: policy.GetGeneration(),
+                               LastTransitionTime: metav1.Now(),
+                               Reason:             
string(gatewayv1.PolicyReasonInvalid),
+                               Message:            secretErr.Error(),
+                       }
+               } else if i == 0 {
                        condition = metav1.Condition{
                                Type:               
string(gatewayv1.PolicyConditionAccepted),
                                Status:             metav1.ConditionTrue,
diff --git a/internal/controller/tcproute_controller.go 
b/internal/controller/tcproute_controller.go
index 4cf16ebe..4df650a3 100644
--- a/internal/controller/tcproute_controller.go
+++ b/internal/controller/tcproute_controller.go
@@ -67,7 +67,14 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
 
        bdr := ctrl.NewControllerManagedBy(mgr).
                For(&gatewayv1.TCPRoute{}).
-               WithEventFilter(predicate.GenerationChangedPredicate{}).
+               // A Secret carries no generation, so 
GenerationChangedPredicate would drop its
+               // updates and a plugin would keep the Secret data read at the 
last spec change.
+               WithEventFilter(
+                       predicate.Or(
+                               predicate.GenerationChangedPredicate{},
+                               
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
+                       ),
+               ).
                Watches(&discoveryv1.EndpointSlice{},
                        
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesByServiceRef),
                ).
@@ -107,7 +114,10 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
        if r.supportsL4RoutePolicy {
                bdr.Watches(&v1alpha1.L4RoutePolicy{},
                        
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForL4RoutePolicy),
-               )
+               ).
+                       Watches(&corev1.Secret{},
+                               
handler.EnqueueRequestsFromMapFunc(r.listTCPRoutesForSecret),
+                       )
        }
 
        if GetEnableReferenceGrant() {
@@ -573,3 +583,12 @@ func (r *TCPRouteReconciler) 
listTCPRoutesForL4RoutePolicy(ctx context.Context,
        }
        return requests
 }
+
+// listTCPRoutesForSecret maps a Secret to the TCPRoutes whose L4RoutePolicy 
plugins reference it.
+func (r *TCPRouteReconciler) listTCPRoutesForSecret(ctx context.Context, obj 
client.Object) []reconcile.Request {
+       var requests []reconcile.Request
+       for _, policy := range listL4RoutePoliciesForSecret(ctx, r.Client, 
r.Log, obj) {
+               requests = append(requests, 
r.listTCPRoutesForL4RoutePolicy(ctx, &policy)...)
+       }
+       return pkgutils.DedupComparable(requests)
+}
diff --git a/internal/controller/tlsroute_controller.go 
b/internal/controller/tlsroute_controller.go
index c9df0e62..3b23f000 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -67,7 +67,14 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
 
        bdr := ctrl.NewControllerManagedBy(mgr).
                For(&gatewayv1.TLSRoute{}).
-               WithEventFilter(predicate.GenerationChangedPredicate{}).
+               // A Secret carries no generation, so 
GenerationChangedPredicate would drop its
+               // updates and a plugin would keep the Secret data read at the 
last spec change.
+               WithEventFilter(
+                       predicate.Or(
+                               predicate.GenerationChangedPredicate{},
+                               
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
+                       ),
+               ).
                Watches(&discoveryv1.EndpointSlice{},
                        
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesByServiceRef),
                ).
@@ -107,7 +114,10 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
        if r.supportsL4RoutePolicy {
                bdr.Watches(&v1alpha1.L4RoutePolicy{},
                        
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForL4RoutePolicy),
-               )
+               ).
+                       Watches(&corev1.Secret{},
+                               
handler.EnqueueRequestsFromMapFunc(r.listTLSRoutesForSecret),
+                       )
        }
 
        if GetEnableReferenceGrant() {
@@ -565,3 +575,12 @@ func (r *TLSRouteReconciler) 
listTLSRoutesForL4RoutePolicy(ctx context.Context,
        }
        return requests
 }
+
+// listTLSRoutesForSecret maps a Secret to the TLSRoutes whose L4RoutePolicy 
plugins reference it.
+func (r *TLSRouteReconciler) listTLSRoutesForSecret(ctx context.Context, obj 
client.Object) []reconcile.Request {
+       var requests []reconcile.Request
+       for _, policy := range listL4RoutePoliciesForSecret(ctx, r.Client, 
r.Log, obj) {
+               requests = append(requests, 
r.listTLSRoutesForL4RoutePolicy(ctx, &policy)...)
+       }
+       return pkgutils.DedupComparable(requests)
+}
diff --git a/internal/controller/udproute_controller.go 
b/internal/controller/udproute_controller.go
index 2c8a910b..31a3ab2a 100644
--- a/internal/controller/udproute_controller.go
+++ b/internal/controller/udproute_controller.go
@@ -67,7 +67,14 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
 
        bdr := ctrl.NewControllerManagedBy(mgr).
                For(&gatewayv1.UDPRoute{}).
-               WithEventFilter(predicate.GenerationChangedPredicate{}).
+               // A Secret carries no generation, so 
GenerationChangedPredicate would drop its
+               // updates and a plugin would keep the Secret data read at the 
last spec change.
+               WithEventFilter(
+                       predicate.Or(
+                               predicate.GenerationChangedPredicate{},
+                               
predicate.NewPredicateFuncs(TypePredicate[*corev1.Secret]()),
+                       ),
+               ).
                Watches(&discoveryv1.EndpointSlice{},
                        
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesByServiceRef),
                ).
@@ -107,7 +114,10 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr 
ctrl.Manager) error {
        if r.supportsL4RoutePolicy {
                bdr.Watches(&v1alpha1.L4RoutePolicy{},
                        
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForL4RoutePolicy),
-               )
+               ).
+                       Watches(&corev1.Secret{},
+                               
handler.EnqueueRequestsFromMapFunc(r.listUDPRoutesForSecret),
+                       )
        }
 
        if GetEnableReferenceGrant() {
@@ -573,3 +583,12 @@ func (r *UDPRouteReconciler) 
listUDPRoutesForL4RoutePolicy(ctx context.Context,
        }
        return requests
 }
+
+// listUDPRoutesForSecret maps a Secret to the UDPRoutes whose L4RoutePolicy 
plugins reference it.
+func (r *UDPRouteReconciler) listUDPRoutesForSecret(ctx context.Context, obj 
client.Object) []reconcile.Request {
+       var requests []reconcile.Request
+       for _, policy := range listL4RoutePoliciesForSecret(ctx, r.Client, 
r.Log, obj) {
+               requests = append(requests, 
r.listUDPRoutesForL4RoutePolicy(ctx, &policy)...)
+       }
+       return pkgutils.DedupComparable(requests)
+}
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index cf5167b4..deceecdd 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -2041,3 +2041,42 @@ func serviceLoadBalancerAddresses(svc *corev1.Service) 
[]string {
        }
        return addrs
 }
+
+// loadPluginSecrets loads the Secrets referenced by 
apisix.apache.org/v1alpha1 plugins
+// into the translate context. A plugin may only reference a Secret in the 
namespace of
+// the object that declares it.
+func loadPluginSecrets(ctx context.Context, c client.Client, tctx 
*provider.TranslateContext, namespace string, plugins []v1alpha1.Plugin) error {
+       for _, plugin := range plugins {
+               if plugin.SecretRef == nil || plugin.SecretRef.Name == "" {
+                       continue
+               }
+               secretNN := k8stypes.NamespacedName{Namespace: namespace, Name: 
plugin.SecretRef.Name}
+               if _, ok := tctx.Secrets[secretNN]; ok {
+                       continue
+               }
+               secret := new(corev1.Secret)
+               if err := c.Get(ctx, secretNN, secret); err != nil {
+                       return fmt.Errorf("failed to get Secret %s referenced 
by plugin %s: %w", secretNN, plugin.Name, err)
+               }
+               tctx.Secrets[secretNN] = secret
+       }
+       return nil
+}
+
+// listL4RoutePoliciesForSecret returns the L4RoutePolicies whose plugins 
reference the
+// given Secret.
+func listL4RoutePoliciesForSecret(ctx context.Context, c client.Client, log 
logr.Logger, obj client.Object) []v1alpha1.L4RoutePolicy {
+       secret, ok := obj.(*corev1.Secret)
+       if !ok {
+               log.Error(errors.New("unexpected object type"), "failed to 
convert object to Secret")
+               return nil
+       }
+       var list v1alpha1.L4RoutePolicyList
+       if err := c.List(ctx, &list, client.MatchingFields{
+               indexer.SecretIndexRef: 
indexer.GenIndexKey(secret.GetNamespace(), secret.GetName()),
+       }); err != nil {
+               log.Error(err, "failed to list L4RoutePolicy by secret 
reference", "secret", utils.NamespacedName(secret))
+               return nil
+       }
+       return list.Items
+}
diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go
index 17783e06..48cc9e47 100644
--- a/test/e2e/gatewayapi/httproute.go
+++ b/test/e2e/gatewayapi/httproute.go
@@ -1873,6 +1873,65 @@ spec:
     config:
       body: "Updated"
 `
+               var echoSecret = `
+apiVersion: v1
+kind: Secret
+metadata:
+  name: echo-secret
+stringData:
+  body: "Hello from Secret"
+  headers.X-Origin: "secret"
+`
+               var echoSecretUpdated = `
+apiVersion: v1
+kind: Secret
+metadata:
+  name: echo-secret
+stringData:
+  body: "Updated from Secret"
+  headers.X-Origin: "secret"
+`
+               var echoPluginWithSecretRef = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: PluginConfig
+metadata:
+  name: example-plugin-config-secret
+spec:
+  plugins:
+  - name: echo
+    secretRef:
+      name: echo-secret
+    config:
+      headers:
+        X-Config: "config"
+`
+               var extensionRefEchoPluginWithSecretRef = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: httpbin-secret
+  namespace: %s
+spec:
+  parentRefs:
+  - name: %s
+  hostnames:
+  - httpbin.example
+  rules:
+  - matches:
+    - path:
+        type: Exact
+        value: /get
+    filters:
+    - type: ExtensionRef
+      extensionRef:
+        group: apisix.apache.org
+        kind: PluginConfig
+        name: example-plugin-config-secret
+    backendRefs:
+    - name: httpbin-service-e2e-test
+      port: 80
+`
+
                var extensionRefEchoPlugin = `
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
@@ -2239,6 +2298,48 @@ spec:
                        })
                })
 
+               It("HTTPRoute ExtensionRef with plugin secretRef", func() {
+                       By("create Secret and PluginConfig")
+                       
Expect(s.CreateResourceFromStringWithNamespace(echoSecret, s.Namespace())).
+                               NotTo(HaveOccurred(), "creating Secret")
+                       
Expect(s.CreateResourceFromStringWithNamespace(echoPluginWithSecretRef, 
s.Namespace())).
+                               NotTo(HaveOccurred(), "creating PluginConfig")
+                       s.ResourceApplied("HTTPRoute", "httpbin-secret", 
fmt.Sprintf(extensionRefEchoPluginWithSecretRef, s.Namespace(), s.Namespace()), 
1)
+
+                       By("the Secret provides the plugin config, spec.config 
is kept")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Method:   "GET",
+                               Path:     "/get",
+                               Host:     "httpbin.example",
+                               Check:    
scaffold.WithExpectedBodyContains("Hello from Secret"),
+                               Timeout:  time.Second * 30,
+                               Interval: time.Second * 2,
+                       })
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Method: "GET",
+                               Path:   "/get",
+                               Host:   "httpbin.example",
+                               Check: 
scaffold.WithExpectedHeaders(map[string]string{
+                                       "X-Config": "config",
+                                       "X-Origin": "secret",
+                               }),
+                               Timeout:  time.Second * 30,
+                               Interval: time.Second * 2,
+                       })
+
+                       By("updating the Secret updates the plugin config")
+                       
Expect(s.CreateResourceFromStringWithNamespace(echoSecretUpdated, 
s.Namespace())).
+                               NotTo(HaveOccurred(), "updating Secret")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Method:   "GET",
+                               Path:     "/get",
+                               Host:     "httpbin.example",
+                               Check:    
scaffold.WithExpectedBodyContains("Updated from Secret"),
+                               Timeout:  time.Second * 30,
+                               Interval: time.Second * 2,
+                       })
+               })
+
                It("HTTPRoute CORS Filter", func() {
                        By("create test service and deployment")
                        
Expect(s.CreateResourceFromStringWithNamespace(corsTestService, s.Namespace())).
diff --git a/test/e2e/gatewayapi/tcproute.go b/test/e2e/gatewayapi/tcproute.go
index cda0b40c..cf5ae447 100644
--- a/test/e2e/gatewayapi/tcproute.go
+++ b/test/e2e/gatewayapi/tcproute.go
@@ -217,6 +217,25 @@ spec:
       - "0.0.0.0/0"
 `
 
+               var l4RoutePolicyMissingSecret = `
+apiVersion: apisix.apache.org/v1alpha1
+kind: L4RoutePolicy
+metadata:
+  name: tcp-block-all
+spec:
+  targetRefs:
+  - group: gateway.networking.k8s.io
+    kind: TCPRoute
+    name: tcp-l4policy
+  plugins:
+  - name: ip-restriction
+    secretRef:
+      name: no-such-secret
+    config:
+      blacklist:
+      - "0.0.0.0/0"
+`
+
                BeforeEach(func() {
                        
Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred(),
 "creating GatewayProxy")
                        
Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).NotTo(HaveOccurred(),
 "creating GatewayClass")
@@ -251,5 +270,26 @@ spec:
                        By("verifying TCP traffic recovers after L4RoutePolicy 
deletion")
                        s.HTTPOverTCPConnectAssert(true, time.Minute*3)
                })
+
+               It("L4RoutePolicy with a missing plugin Secret is rejected", 
func() {
+                       By("creating TCPRoute")
+                       s.ResourceApplied("TCPRoute", "tcp-l4policy", 
fmt.Sprintf(tcpRoute, s.Namespace()), 1)
+                       s.HTTPOverTCPConnectAssert(true, time.Minute*3)
+
+                       By("applying an L4RoutePolicy whose plugin references a 
Secret that does not exist")
+                       s.ApplyL4RoutePolicy(
+                               types.NamespacedName{Name: s.Namespace()},
+                               types.NamespacedName{Namespace: s.Namespace(), 
Name: "tcp-block-all"},
+                               l4RoutePolicyMissingSecret,
+                               metav1.Condition{
+                                       Type:   
string(gatewayv1.PolicyConditionAccepted),
+                                       Status: metav1.ConditionFalse,
+                                       Reason: 
string(gatewayv1.PolicyReasonInvalid),
+                               },
+                       )
+
+                       By("verifying the policy plugins are not attached")
+                       s.HTTPOverTCPConnectAssert(true, time.Minute*3)
+               })
        })
 })

Reply via email to