AlinsRan commented on code in PR #13509:
URL: https://github.com/apache/apisix/pull/13509#discussion_r3549401535


##########
apisix/secret/kubernetes.lua:
##########
@@ -0,0 +1,262 @@
+--
+-- 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.
+--
+
+--- Kubernetes Secret Manager.
+--  Fetches secrets from the Kubernetes API server using the pod's 
ServiceAccount
+--  token. This allows APISIX to read Kubernetes Secrets directly from the 
cluster
+--  it is running in, without requiring an external secrets management service.
+--
+--  URI format:
+--    $secret://kubernetes/{manager-id}/{namespace}/{secret-name}/{data-key}
+--
+--  Example:
+--    $secret://kubernetes/my-k8s/default/my-secret/password
+--
+--  The manager is configured once via the Admin API:
+--    PUT /apisix/admin/secrets/kubernetes/my-k8s
+--    {
+--      "service_account_file": 
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+--      "kubernetes_host": "kubernetes.default.svc",
+--      "kubernetes_port": "443",
+--      "ssl_verify": true
+--    }
+--
+--  All fields have sensible defaults for in-cluster usage, so the minimal
+--  valid configuration is an empty object `{}`.
+--
+--  TLS note: when ssl_verify is true (the default), the TLS handshake is
+--  validated against the nginx-level lua_ssl_trusted_certificate bundle.
+--  For in-cluster usage, set apisix.ssl.ssl_trusted_certificate in config.yaml
+--  to /var/run/secrets/kubernetes.io/serviceaccount/ca.crt so that the
+--  kube-apiserver certificate (signed by the cluster CA) is trusted.
+
+local core = require("apisix.core")
+local http = require("resty.http")
+local env  = core.env
+
+local io_open = io.open
+local find    = core.string.find
+local sub     = core.string.sub
+local ngx_decode_base64 = ngx.decode_base64
+
+local DEFAULT_SA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
+
+local schema = {
+    type = "object",
+    properties = {
+        service_account_file = {
+            type = "string",
+            description = "Path to the ServiceAccount token file. "
+                       .. "Defaults to the standard in-cluster path.",
+            default = DEFAULT_SA_FILE,
+        },
+        kubernetes_host = {
+            type = "string",
+            description = "Kubernetes API server hostname or IP. "
+                       .. "Defaults to the KUBERNETES_SERVICE_HOST environment 
variable.",
+        },
+        kubernetes_port = {
+            type = "string",
+            description = "Kubernetes API server port. "
+                       .. "Defaults to the KUBERNETES_SERVICE_PORT environment 
variable.",
+        },
+        endpoint = {
+            type = "string",
+            description = "Full base URL of the Kubernetes API server "
+                       .. "(e.g. https://kubernetes.default.svc:443). "
+                       .. "When set, kubernetes_host and kubernetes_port are 
ignored. "
+                       .. "Useful for testing with plain HTTP mock servers.",
+        },
+        ssl_verify = {
+            type = "boolean",
+            description = "Verify the Kubernetes API server TLS certificate. "
+                       .. "When true, validation uses the nginx-level "
+                       .. "lua_ssl_trusted_certificate bundle; set "
+                       .. "apisix.ssl.ssl_trusted_certificate to the cluster 
CA "
+                       .. 
"(/var/run/secrets/kubernetes.io/serviceaccount/ca.crt) "
+                       .. "in config.yaml for in-cluster usage.",
+            default = true,
+        },
+    },
+    required = {},
+}
+
+local _M = {
+    schema = schema,
+}
+
+
+local function read_file(path)
+    local f, err = io_open(path, "r")
+    if not f then
+        return nil, "failed to open file " .. path .. ": " .. err
+    end
+    local content = f:read("*a")
+    f:close()
+    if not content or content == "" then
+        return nil, "file is empty: " .. path
+    end
+    return content
+end
+
+
+local function make_request_to_k8s(conf, namespace, secret_name)
+    local sa_file = conf.service_account_file or DEFAULT_SA_FILE
+    local token, err = read_file(sa_file)
+    if not token then
+        return nil, err
+    end
+    -- strip trailing newline
+    token = token:gsub("%s+$", "")
+
+    local k8s_host = conf.kubernetes_host
+                  or env.fetch_by_uri("$ENV://KUBERNETES_SERVICE_HOST")
+                  or os.getenv("KUBERNETES_SERVICE_HOST")

Review Comment:
   This is the lint failure: `ERROR: apisix/secret/kubernetes.lua: line 128: 
getting the Lua global "os"` (same on line 132).
   
   Use `core.env.get("KUBERNETES_SERVICE_HOST")` instead. 
`apisix/discovery/kubernetes/core.lua:74` already made this switch — the 
comment there explains it works around a lua-resty-core prefix-collision bug 
with `os.getenv` during worker init (#13055).
   
   The fallback is also redundant: `env.fetch_by_uri("$ENV://...")` on the line 
above already goes through `core.env`'s `_M.get`, which checks the environ 
snapshot and then falls back to `os.getenv` itself. So `core.env.get(...)` 
alone covers both branches.



##########
apisix/secret/kubernetes.lua:
##########
@@ -0,0 +1,262 @@
+--
+-- 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.
+--
+
+--- Kubernetes Secret Manager.
+--  Fetches secrets from the Kubernetes API server using the pod's 
ServiceAccount
+--  token. This allows APISIX to read Kubernetes Secrets directly from the 
cluster
+--  it is running in, without requiring an external secrets management service.
+--
+--  URI format:
+--    $secret://kubernetes/{manager-id}/{namespace}/{secret-name}/{data-key}
+--
+--  Example:
+--    $secret://kubernetes/my-k8s/default/my-secret/password
+--
+--  The manager is configured once via the Admin API:
+--    PUT /apisix/admin/secrets/kubernetes/my-k8s
+--    {
+--      "service_account_file": 
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+--      "kubernetes_host": "kubernetes.default.svc",
+--      "kubernetes_port": "443",
+--      "ssl_verify": true
+--    }
+--
+--  All fields have sensible defaults for in-cluster usage, so the minimal
+--  valid configuration is an empty object `{}`.
+--
+--  TLS note: when ssl_verify is true (the default), the TLS handshake is
+--  validated against the nginx-level lua_ssl_trusted_certificate bundle.
+--  For in-cluster usage, set apisix.ssl.ssl_trusted_certificate in config.yaml
+--  to /var/run/secrets/kubernetes.io/serviceaccount/ca.crt so that the
+--  kube-apiserver certificate (signed by the cluster CA) is trusted.
+
+local core = require("apisix.core")
+local http = require("resty.http")
+local env  = core.env
+
+local io_open = io.open
+local find    = core.string.find
+local sub     = core.string.sub
+local ngx_decode_base64 = ngx.decode_base64
+
+local DEFAULT_SA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
+
+local schema = {
+    type = "object",
+    properties = {
+        service_account_file = {
+            type = "string",
+            description = "Path to the ServiceAccount token file. "
+                       .. "Defaults to the standard in-cluster path.",
+            default = DEFAULT_SA_FILE,
+        },
+        kubernetes_host = {
+            type = "string",
+            description = "Kubernetes API server hostname or IP. "
+                       .. "Defaults to the KUBERNETES_SERVICE_HOST environment 
variable.",
+        },
+        kubernetes_port = {
+            type = "string",
+            description = "Kubernetes API server port. "
+                       .. "Defaults to the KUBERNETES_SERVICE_PORT environment 
variable.",
+        },
+        endpoint = {
+            type = "string",
+            description = "Full base URL of the Kubernetes API server "
+                       .. "(e.g. https://kubernetes.default.svc:443). "
+                       .. "When set, kubernetes_host and kubernetes_port are 
ignored. "
+                       .. "Useful for testing with plain HTTP mock servers.",
+        },
+        ssl_verify = {
+            type = "boolean",
+            description = "Verify the Kubernetes API server TLS certificate. "
+                       .. "When true, validation uses the nginx-level "
+                       .. "lua_ssl_trusted_certificate bundle; set "
+                       .. "apisix.ssl.ssl_trusted_certificate to the cluster 
CA "
+                       .. 
"(/var/run/secrets/kubernetes.io/serviceaccount/ca.crt) "
+                       .. "in config.yaml for in-cluster usage.",
+            default = true,
+        },
+    },
+    required = {},
+}
+
+local _M = {
+    schema = schema,
+}
+
+
+local function read_file(path)
+    local f, err = io_open(path, "r")
+    if not f then
+        return nil, "failed to open file " .. path .. ": " .. err
+    end
+    local content = f:read("*a")
+    f:close()
+    if not content or content == "" then
+        return nil, "file is empty: " .. path
+    end
+    return content
+end
+
+
+local function make_request_to_k8s(conf, namespace, secret_name)
+    local sa_file = conf.service_account_file or DEFAULT_SA_FILE
+    local token, err = read_file(sa_file)
+    if not token then
+        return nil, err
+    end
+    -- strip trailing newline
+    token = token:gsub("%s+$", "")
+
+    local k8s_host = conf.kubernetes_host

Review Comment:
   Heads up on the in-cluster "just works" path: nginx workers only see env 
vars declared with an `env` directive. `apisix/cli/ngx_tpl.lua` emits 
`APISIX_PROFILE`, `PATH`, `GCP_SERVICE_ACCOUNT` etc., but not 
`KUBERNETES_SERVICE_HOST`/`KUBERNETES_SERVICE_PORT`. So with the documented 
minimal config (`{}`) this resolves to nil and you get `kubernetes_host is not 
set and KUBERNETES_SERVICE_HOST env var is missing`.
   
   Either document that users must declare both under `nginx_config.envs` in 
config.yaml, or drop the env fallback and require `kubernetes_host`. Right now 
the doc table presents `$KUBERNETES_SERVICE_HOST` as a working default.
   
   Worth noting this branch has no test coverage at all — every test passes an 
explicit `endpoint`.



##########
apisix/secret/kubernetes.lua:
##########
@@ -0,0 +1,262 @@
+--
+-- 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.
+--
+
+--- Kubernetes Secret Manager.
+--  Fetches secrets from the Kubernetes API server using the pod's 
ServiceAccount
+--  token. This allows APISIX to read Kubernetes Secrets directly from the 
cluster
+--  it is running in, without requiring an external secrets management service.
+--
+--  URI format:
+--    $secret://kubernetes/{manager-id}/{namespace}/{secret-name}/{data-key}
+--
+--  Example:
+--    $secret://kubernetes/my-k8s/default/my-secret/password
+--
+--  The manager is configured once via the Admin API:
+--    PUT /apisix/admin/secrets/kubernetes/my-k8s
+--    {
+--      "service_account_file": 
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+--      "kubernetes_host": "kubernetes.default.svc",
+--      "kubernetes_port": "443",
+--      "ssl_verify": true
+--    }
+--
+--  All fields have sensible defaults for in-cluster usage, so the minimal
+--  valid configuration is an empty object `{}`.
+--
+--  TLS note: when ssl_verify is true (the default), the TLS handshake is
+--  validated against the nginx-level lua_ssl_trusted_certificate bundle.
+--  For in-cluster usage, set apisix.ssl.ssl_trusted_certificate in config.yaml
+--  to /var/run/secrets/kubernetes.io/serviceaccount/ca.crt so that the
+--  kube-apiserver certificate (signed by the cluster CA) is trusted.
+
+local core = require("apisix.core")
+local http = require("resty.http")
+local env  = core.env
+
+local io_open = io.open
+local find    = core.string.find
+local sub     = core.string.sub
+local ngx_decode_base64 = ngx.decode_base64
+
+local DEFAULT_SA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
+
+local schema = {
+    type = "object",
+    properties = {
+        service_account_file = {
+            type = "string",
+            description = "Path to the ServiceAccount token file. "
+                       .. "Defaults to the standard in-cluster path.",
+            default = DEFAULT_SA_FILE,
+        },
+        kubernetes_host = {
+            type = "string",
+            description = "Kubernetes API server hostname or IP. "
+                       .. "Defaults to the KUBERNETES_SERVICE_HOST environment 
variable.",
+        },
+        kubernetes_port = {
+            type = "string",
+            description = "Kubernetes API server port. "
+                       .. "Defaults to the KUBERNETES_SERVICE_PORT environment 
variable.",
+        },
+        endpoint = {
+            type = "string",
+            description = "Full base URL of the Kubernetes API server "
+                       .. "(e.g. https://kubernetes.default.svc:443). "
+                       .. "When set, kubernetes_host and kubernetes_port are 
ignored. "
+                       .. "Useful for testing with plain HTTP mock servers.",
+        },
+        ssl_verify = {
+            type = "boolean",
+            description = "Verify the Kubernetes API server TLS certificate. "
+                       .. "When true, validation uses the nginx-level "
+                       .. "lua_ssl_trusted_certificate bundle; set "
+                       .. "apisix.ssl.ssl_trusted_certificate to the cluster 
CA "
+                       .. 
"(/var/run/secrets/kubernetes.io/serviceaccount/ca.crt) "
+                       .. "in config.yaml for in-cluster usage.",
+            default = true,
+        },
+    },
+    required = {},

Review Comment:
   This line makes the whole manager unusable. An empty Lua table serializes to 
an empty JSON **object**, not an array, so `jsonschema` reads it as "the input 
must be an empty table" and rejects any config that has properties:
   
   ```
   required={} + {}                  -> OK
   required={} + {ssl_verify=true}   -> FAIL: the input data should be an empty 
table
   no required + {ssl_verify=true}   -> OK
   ```
   
   Both `apisix/secret.lua:52` (`check_secret`) and 
`apisix/admin/secrets.lua:35` run `core.schema.check(secret_manager.schema, 
conf)`, so `PUT /apisix/admin/secrets/kubernetes/{id}` is rejected for every 
non-empty body. That's why TEST 13 returns `{"error_msg":"invalid 
configuration: the input data should be an empty table"}` and TEST 14 then 
cascades to `no secret conf`.
   
   Just delete the line — `vault.lua`, `aws.lua` and `gcp.lua` all omit 
`required` when nothing is mandatory.



##########
docs/en/latest/terminology/secret.md:
##########
@@ -363,3 +364,130 @@ curl http://127.0.0.1:9180/apisix/admin/secrets/gcp/1 \
 }'
 
 ```
+
+## Use Kubernetes Secrets to manage secrets
+
+When APISIX is running inside a Kubernetes cluster, it can read secrets 
directly
+from the Kubernetes API server using the pod's ServiceAccount credentials. This
+allows you to manage APISIX plugin credentials through standard Kubernetes 
Secrets
+without requiring an external secrets management service.
+
+### Prerequisites
+
+The ServiceAccount used by the APISIX pod must have RBAC permissions to read
+the target Secrets. Create a `ClusterRole` and bind it to the APISIX 
ServiceAccount:
+
+```yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: apisix-secret-reader
+rules:
+  - apiGroups: [""]
+    resources: ["secrets"]
+    verbs: ["get"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: apisix-secret-reader
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: apisix-secret-reader
+subjects:
+  - kind: ServiceAccount
+    name: apisix
+    namespace: apisix
+```
+
+### Usage
+
+```
+$secret://kubernetes/{manager-id}/{namespace}/{secret-name}/{data-key}
+```
+
+- `manager-id`: the ID of the Kubernetes secret manager instance registered 
via Admin API
+- `namespace`: the Kubernetes namespace where the Secret lives
+- `secret-name`: the name of the Kubernetes Secret
+- `data-key`: the key within `Secret.data` (the value will be base64-decoded 
automatically)
+
+### Configuration via Admin API
+
+Register a Kubernetes secret manager instance. All fields are optional and 
default
+to standard in-cluster values:
+
+```bash
+curl -X PUT http://127.0.0.1:9180/apisix/admin/secrets/kubernetes/my-k8s \
+  -H "X-API-KEY: $ADMIN_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "service_account_file": 
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+    "kubernetes_host": "kubernetes.default.svc",
+    "kubernetes_port": "443",
+    "ssl_verify": true
+  }'
+```
+
+| Field | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `service_account_file` | string | No | 
`/var/run/secrets/kubernetes.io/serviceaccount/token` | Path to the 
ServiceAccount token file |
+| `kubernetes_host` | string | No | `$KUBERNETES_SERVICE_HOST` env var | 
Kubernetes API server hostname or IP |
+| `kubernetes_port` | string | No | `$KUBERNETES_SERVICE_PORT` env var | 
Kubernetes API server port |
+| `endpoint` | string | No | derived from `kubernetes_host`/`kubernetes_port` 
| Full base URL of the API server (e.g. `https://kubernetes.default.svc:443`). 
Overrides `kubernetes_host` and `kubernetes_port` when set |
+| `ssl_verify` | boolean | No | `true` | Verify the Kubernetes API server TLS 
certificate |
+
+#### TLS configuration for in-cluster usage
+
+When `ssl_verify` is `true` (the default), TLS verification uses the 
nginx-level
+`lua_ssl_trusted_certificate` bundle. The cluster CA that signs the 
kube-apiserver
+certificate is **not** in the system CA bundle, so you must add it explicitly 
in
+`config.yaml`:
+
+```yaml
+apisix:
+  ssl:
+    ssl_trusted_certificate: 
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
+```
+
+Without this setting the TLS handshake will fail. Alternatively, set 
`ssl_verify: false`,
+but this is not recommended in production because the ServiceAccount token is 
sent
+to an unverified endpoint.
+
+### Example: protect plugin credentials with Kubernetes Secrets
+
+Suppose you have a Kubernetes Secret in the `my-app` namespace:
+
+```bash
+kubectl create secret generic keycloak-creds \
+  --namespace my-app \
+  --from-literal=client_id=my-client-id \
+  --from-literal=client_secret=my-client-secret
+```
+
+You can reference these values in the `authz-keycloak` plugin configuration:
+
+```bash
+curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
+  -H "X-API-KEY: $ADMIN_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "uri": "/api/*",
+    "plugins": {
+      "authz-keycloak": {
+        "discovery": 
"https://keycloak.example.com/auth/realms/my-realm/.well-known/uma2-configuration";,
+        "client_id": 
"$secret://kubernetes/my-k8s/my-app/keycloak-creds/client_id",
+        "client_secret": 
"$secret://kubernetes/my-k8s/my-app/keycloak-creds/client_secret",
+        "policy_enforcement_mode": "ENFORCING"
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {"backend-service:8080": 1}
+    }
+  }'
+```
+
+APISIX resolves `$secret://kubernetes/...` references at request time by 
calling

Review Comment:
   This overstates it. `apisix/plugin.lua:113` calls 
`secret.fetch_secrets(conf, true)` with `use_cache=true`, and 
`apisix/secret.lua` caches resolved values in an lrucache with a 300s TTL (60s 
neg_ttl). So most requests never reach the Kubernetes API, and a rotated secret 
stays stale for up to ~5 minutes. Worth saying that explicitly, since "picked 
up automatically" reads as immediate.



##########
apisix/secret/kubernetes.lua:
##########
@@ -0,0 +1,262 @@
+--
+-- 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.
+--
+
+--- Kubernetes Secret Manager.
+--  Fetches secrets from the Kubernetes API server using the pod's 
ServiceAccount
+--  token. This allows APISIX to read Kubernetes Secrets directly from the 
cluster
+--  it is running in, without requiring an external secrets management service.
+--
+--  URI format:
+--    $secret://kubernetes/{manager-id}/{namespace}/{secret-name}/{data-key}
+--
+--  Example:
+--    $secret://kubernetes/my-k8s/default/my-secret/password
+--
+--  The manager is configured once via the Admin API:
+--    PUT /apisix/admin/secrets/kubernetes/my-k8s
+--    {
+--      "service_account_file": 
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+--      "kubernetes_host": "kubernetes.default.svc",
+--      "kubernetes_port": "443",
+--      "ssl_verify": true
+--    }
+--
+--  All fields have sensible defaults for in-cluster usage, so the minimal
+--  valid configuration is an empty object `{}`.
+--
+--  TLS note: when ssl_verify is true (the default), the TLS handshake is
+--  validated against the nginx-level lua_ssl_trusted_certificate bundle.
+--  For in-cluster usage, set apisix.ssl.ssl_trusted_certificate in config.yaml
+--  to /var/run/secrets/kubernetes.io/serviceaccount/ca.crt so that the
+--  kube-apiserver certificate (signed by the cluster CA) is trusted.
+
+local core = require("apisix.core")
+local http = require("resty.http")
+local env  = core.env
+
+local io_open = io.open
+local find    = core.string.find
+local sub     = core.string.sub
+local ngx_decode_base64 = ngx.decode_base64
+
+local DEFAULT_SA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
+
+local schema = {
+    type = "object",
+    properties = {
+        service_account_file = {
+            type = "string",
+            description = "Path to the ServiceAccount token file. "
+                       .. "Defaults to the standard in-cluster path.",
+            default = DEFAULT_SA_FILE,
+        },
+        kubernetes_host = {
+            type = "string",
+            description = "Kubernetes API server hostname or IP. "
+                       .. "Defaults to the KUBERNETES_SERVICE_HOST environment 
variable.",
+        },
+        kubernetes_port = {
+            type = "string",
+            description = "Kubernetes API server port. "
+                       .. "Defaults to the KUBERNETES_SERVICE_PORT environment 
variable.",
+        },
+        endpoint = {
+            type = "string",
+            description = "Full base URL of the Kubernetes API server "
+                       .. "(e.g. https://kubernetes.default.svc:443). "
+                       .. "When set, kubernetes_host and kubernetes_port are 
ignored. "
+                       .. "Useful for testing with plain HTTP mock servers.",
+        },
+        ssl_verify = {
+            type = "boolean",
+            description = "Verify the Kubernetes API server TLS certificate. "
+                       .. "When true, validation uses the nginx-level "
+                       .. "lua_ssl_trusted_certificate bundle; set "
+                       .. "apisix.ssl.ssl_trusted_certificate to the cluster 
CA "
+                       .. 
"(/var/run/secrets/kubernetes.io/serviceaccount/ca.crt) "
+                       .. "in config.yaml for in-cluster usage.",
+            default = true,
+        },
+    },
+    required = {},
+}
+
+local _M = {
+    schema = schema,
+}
+
+
+local function read_file(path)
+    local f, err = io_open(path, "r")
+    if not f then
+        return nil, "failed to open file " .. path .. ": " .. err
+    end
+    local content = f:read("*a")
+    f:close()
+    if not content or content == "" then
+        return nil, "file is empty: " .. path
+    end
+    return content
+end
+
+
+local function make_request_to_k8s(conf, namespace, secret_name)
+    local sa_file = conf.service_account_file or DEFAULT_SA_FILE
+    local token, err = read_file(sa_file)
+    if not token then
+        return nil, err
+    end
+    -- strip trailing newline
+    token = token:gsub("%s+$", "")
+
+    local k8s_host = conf.kubernetes_host
+                  or env.fetch_by_uri("$ENV://KUBERNETES_SERVICE_HOST")
+                  or os.getenv("KUBERNETES_SERVICE_HOST")
+
+    local k8s_port = conf.kubernetes_port
+                  or env.fetch_by_uri("$ENV://KUBERNETES_SERVICE_PORT")
+                  or os.getenv("KUBERNETES_SERVICE_PORT")
+                  or "443"
+
+    local base_url
+    if conf.endpoint then
+        base_url = conf.endpoint
+    else
+        if not k8s_host then
+            return nil, "kubernetes_host is not set and 
KUBERNETES_SERVICE_HOST env var is missing"
+        end
+        base_url = "https://"; .. k8s_host .. ":" .. k8s_port
+    end
+
+    local uri = base_url
+             .. "/api/v1/namespaces/" .. namespace
+             .. "/secrets/" .. secret_name
+
+    core.log.info("fetching Kubernetes secret from: ", uri)
+
+    local httpc = http.new()
+    httpc:set_timeout(5000)
+
+    local ssl_verify = conf.ssl_verify
+    if ssl_verify == nil then
+        ssl_verify = true
+    end
+
+    local request_opts = {
+        method = "GET",
+        headers = {
+            ["Authorization"] = "Bearer " .. token,
+            ["Accept"] = "application/json",
+        },
+        ssl_verify = ssl_verify,
+    }
+
+    local res, req_err = httpc:request_uri(uri, request_opts)
+    if not res then
+        return nil, "failed to request Kubernetes API: " .. req_err
+    end
+
+    if res.status == 401 or res.status == 403 then
+        return nil, "unauthorized to read Kubernetes secret "
+               .. namespace .. "/" .. secret_name
+               .. " (HTTP " .. res.status .. "): check RBAC permissions for 
the ServiceAccount"
+    end
+
+    if res.status == 404 then
+        return nil, "Kubernetes secret not found: " .. namespace .. "/" .. 
secret_name
+    end
+
+    if res.status ~= 200 then
+        return nil, "unexpected HTTP status " .. res.status
+               .. " from Kubernetes API for secret "
+               .. namespace .. "/" .. secret_name
+    end
+
+    return res.body
+end
+
+
+-- key format: {namespace}/{secret-name}/{data-key}
+local function get(conf, key)
+    core.log.info("fetching data from Kubernetes secret for key: ", key)
+
+    local idx1 = find(key, "/")
+    if not idx1 then
+        return nil, "invalid key format, expected 
{namespace}/{secret-name}/{data-key}, got: "
+               .. key
+    end
+
+    local namespace = sub(key, 1, idx1 - 1)
+    if namespace == "" then
+        return nil, "namespace is empty in key: " .. key

Review Comment:
   `namespace` and `secret_name` go into the request path unescaped. 
`resty.http`'s `_format_request` inserts the path verbatim, and its `parse_uri` 
splits on `([^\?]*)`, so `?`, `#` and CRLF all pass straight through to the 
kube-apiserver. A ref like 
`$secret://kubernetes/mgr/default?labelSelector=x/mysecret/key` emits `GET 
/api/v1/namespaces/default?labelSelector=x/secrets/mysecret`.
   
   This isn't a vulnerability — per the threat model 
(`docs/en/latest/security-threat-model.md` §4.2/§4.3) the Admin API operator is 
trusted and already has arbitrary Lua via `script`. But validating against the 
DNS-1123 rules (namespace `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, secret name as DNS 
subdomain) is cheap, kills the class, and turns a malformed ref into a clear 
error instead of a silently reshaped apiserver call.



##########
t/secret/kubernetes.t:
##########
@@ -0,0 +1,463 @@
+#
+# 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.
+#
+
+BEGIN {
+    $ENV{KUBERNETES_SERVICE_HOST} = "127.0.0.1";
+    $ENV{KUBERNETES_SERVICE_PORT} = "6443";
+}
+
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+log_level("info");
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: schema validation - all fields valid
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local core = require("apisix.core")
+
+            local test_cases = {
+                {},
+                {ssl_verify = true},
+                {ssl_verify = false},
+                {ssl_verify = 1234},
+                {service_account_file = 
"/var/run/secrets/kubernetes.io/serviceaccount/token"},
+                {service_account_file = 1234},
+                {kubernetes_host = "kubernetes.default.svc"},
+                {kubernetes_host = 1234},
+                {kubernetes_port = "443"},
+                {kubernetes_port = 1234},
+                {
+                    service_account_file = 
"/var/run/secrets/kubernetes.io/serviceaccount/token",
+                    kubernetes_host = "kubernetes.default.svc",
+                    kubernetes_port = "443",
+                    ssl_verify = true,
+                },
+            }
+
+            for _, conf in ipairs(test_cases) do
+                local ok, err = core.schema.check(kubernetes.schema, conf)
+                ngx.say(ok and "done" or err)
+            end
+        }
+    }
+--- request
+GET /t
+--- response_body
+done
+done
+done
+property "ssl_verify" validation failed: wrong type: expected boolean, got 
number
+done
+property "service_account_file" validation failed: wrong type: expected 
string, got number
+done
+property "kubernetes_host" validation failed: wrong type: expected string, got 
number
+done
+property "kubernetes_port" validation failed: wrong type: expected string, got 
number
+done
+
+
+
+=== TEST 2: get - invalid key format (no slashes)
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local data, err = kubernetes.get({}, "no-slashes")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body
+invalid key format, expected {namespace}/{secret-name}/{data-key}, got: 
no-slashes
+
+
+
+=== TEST 3: get - invalid key format (only one slash, missing data-key)
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local data, err = kubernetes.get({}, "default/my-secret")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body
+invalid key format, missing data-key, expected 
{namespace}/{secret-name}/{data-key}, got: default/my-secret
+
+
+
+=== TEST 4: get - empty namespace
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local data, err = kubernetes.get({}, "/my-secret/my-key")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body
+namespace is empty in key: /my-secret/my-key
+
+
+
+=== TEST 5: get - empty secret-name
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local data, err = kubernetes.get({}, "default//my-key")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body
+secret-name is empty in key: default//my-key
+
+
+
+=== TEST 6: get - empty data-key
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local data, err = kubernetes.get({}, "default/my-secret/")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body
+data-key is empty in key: default/my-secret/
+
+
+
+=== TEST 7: get - service account file not found
+--- config
+    location /t {
+        content_by_lua_block {
+            local kubernetes = require("apisix.secret.kubernetes")
+            local conf = {
+                service_account_file = "/nonexistent/token",
+                ssl_verify = false,
+            }
+            local data, err = kubernetes.get(conf, "default/my-secret/my-key")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body_like
+failed to open file /nonexistent/token:.*
+
+
+
+=== TEST 8: get - connection refused (API server not reachable)
+--- config
+    location /t {
+        content_by_lua_block {
+            -- Write a temp token file for the test
+            local f = io.open("/tmp/test-sa-token", "w")
+            f:write("test-token")
+            f:close()
+
+            local kubernetes = require("apisix.secret.kubernetes")
+            local conf = {
+                service_account_file = "/tmp/test-sa-token",
+                endpoint = "https://127.0.0.2:9999";,
+                ssl_verify = false,
+            }
+            local data, err = kubernetes.get(conf, "default/my-secret/my-key")
+            if err then
+                return ngx.say(err)
+            end
+            ngx.say(data)
+        }
+    }
+--- request
+GET /t
+--- response_body_like
+failed to request Kubernetes API:.*
+--- timeout: 6
+
+
+
+=== TEST 9: get - mock Kubernetes API returns 401
+--- config
+    location /mock-k8s-401 {

Review Comment:
   This test fails in CI, and as written it can't test what its name says. The 
mock is at `/mock-k8s-401`, but the code requests 
`/api/v1/namespaces/default/secrets/my-secret` — no matching location, so it 
hits APISIX's catch-all and gets a 404. `get()` maps that to `Kubernetes secret 
not found: default/my-secret`, which isn't in the `(unauthorized...|failed to 
request...|unexpected HTTP status)` alternation. CI:
   
   ```
   Failed test 'TEST 9: get - mock Kubernetes API returns 401 - 
response_body_like
     - response is expected (Kubernetes secret not found: default/my-secret)'
   ```
   
   Move the mock to `location ~ 
"^/api/v1/namespaces/default/secrets/my-secret$"` returning 401, and assert the 
specific message `unauthorized to read Kubernetes secret default/my-secret 
(HTTP 401)`. The three-way alternation would pass on almost any failure anyway.
   
   While you're here: the `BEGIN { $ENV{KUBERNETES_SERVICE_HOST} = ... }` block 
at the top is dead — no test exercises the env fallback. And a few branches are 
untested: non-200/401/403/404 status, malformed JSON, a secret with no `data` 
field, and the `ngx.decode_base64` nil path.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


Reply via email to