Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apisix/plugins/authz-keycloak.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ local schema = {
discovery = {type = "string", minLength = 1, maxLength = 4096},
token_endpoint = {type = "string", minLength = 1, maxLength = 4096},
resource_registration_endpoint = {type = "string", minLength = 1, maxLength = 4096},
client_id = {type = "string", minLength = 1, maxLength = 100},
client_secret = {type = "string", minLength = 1, maxLength = 100},
client_id = {type = "string", minLength = 1, maxLength = 4096},
client_secret = {type = "string", minLength = 1, maxLength = 4096},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rationale for this bump does not hold on master. Since #13312, core.schema.check skips validation for $secret:// / $env:// references on string fields (skip_validation = secret.is_secret_ref in apisix/core/schema.lua), so a long reference string already passes the 100-char limit. And on reload, plugin_checker runs decrypt_conf before the schema check, so validation sees the original value, not the AES output — what #13493 describes is real on 3.16.0 but is already addressed here.

Raising the limit might still be worth doing for genuinely long client secrets, but then it should be justified on those grounds; otherwise I would drop this hunk from the PR and keep it focused on the new secret manager.

grant_type = {
type = "string",
default="urn:ietf:params:oauth:grant-type:uma-ticket",
Expand Down
242 changes: 242 additions & 0 deletions apisix/secret/kubernetes.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
--
-- 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 `{}`.

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 DEFAULT_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"

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.",
},
ssl_verify = {
type = "boolean",
description = "Verify the Kubernetes API server TLS certificate.",
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")
if not k8s_host then
return nil, "kubernetes_host is not set and KUBERNETES_SERVICE_HOST env var is missing"
end

local k8s_port = conf.kubernetes_port
or env.fetch_by_uri("$ENV://KUBERNETES_SERVICE_PORT")
or os.getenv("KUBERNETES_SERVICE_PORT")
or "443"

local uri = "https://" .. k8s_host .. ":" .. k8s_port

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding https:// makes the bundled tests impossible to pass: the mock locations in t/secret/kubernetes.t serve plain HTTP on port 1984, so TESTs 10-14 will fail at the TLS handshake before ever reaching the mock. gcp.lua handles this by letting the full endpoint be configured (entries_uri), which is exactly what its tests use to mock with http://127.0.0.1:1984. I would suggest the same here — e.g. an optional scheme/endpoint override defaulting to https — rather than rewriting the tests around a TLS mock.

Separately, TEST 14 will still fail after that: the manager registered in TEST 13 does not set service_account_file, so get() falls back to /var/run/secrets/kubernetes.io/serviceaccount/token, which does not exist in CI. Worth running the suite locally before the next push.

.. "/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,
}

if ssl_verify then
request_opts.ssl_trusted_certificate = DEFAULT_CA_FILE

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ssl_trusted_certificate is not a supported request_uri option in lua-resty-http (the api7 fork APISIX uses only honors ssl_verify, ssl_server_name and ssl_send_status_req in http_connect.lua), so this line is silently ignored. With ssl_verify = true, the handshake verifies against the nginx-level lua_ssl_trusted_certificate, which defaults to the system CA bundle (apisix.ssl.ssl_trusted_certificate: system) — and the kube-apiserver cert is signed by the cluster CA, not a public one. So the default config will fail the TLS handshake in a real cluster, and the obvious workaround users will reach for is ssl_verify: false, which means sending the ServiceAccount token over an unverified connection.

I think this needs to either drop the no-op option and document that apisix.ssl.ssl_trusted_certificate must be set to /var/run/secrets/kubernetes.io/serviceaccount/ca.crt (the doc currently claims the in-cluster CA is used by default, which is not accurate), or find another way to actually load the cluster CA.

end

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
end

local rest = sub(key, idx1 + 1)
local idx2 = find(rest, "/")
if not idx2 then
return nil, "invalid key format, missing data-key, expected "
.. "{namespace}/{secret-name}/{data-key}, got: " .. key
end

local secret_name = sub(rest, 1, idx2 - 1)
if secret_name == "" then
return nil, "secret-name is empty in key: " .. key
end

local data_key = sub(rest, idx2 + 1)
if data_key == "" then
return nil, "data-key is empty in key: " .. key
end

core.log.info("namespace: ", namespace,
", secret_name: ", secret_name,
", data_key: ", data_key)

local body, err = make_request_to_k8s(conf, namespace, secret_name)
if not body then
return nil, err
end

local secret, decode_err = core.json.decode(body)
if not secret then
return nil, "failed to decode Kubernetes API response: " .. decode_err
end

if not secret.data then
return nil, "Kubernetes secret " .. namespace .. "/" .. secret_name
.. " has no data field"
end

local encoded_value = secret.data[data_key]
if not encoded_value then
return nil, "key '" .. data_key .. "' not found in Kubernetes secret "
.. namespace .. "/" .. secret_name
end

local value = ngx_decode_base64(encoded_value)
if not value then
return nil, "failed to base64-decode value for key '" .. data_key
.. "' in Kubernetes secret " .. namespace .. "/" .. secret_name
end

return value
end

_M.get = get


return _M
110 changes: 110 additions & 0 deletions docs/en/latest/terminology/secret.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ APISIX currently supports storing secrets in the following ways:
- [HashiCorp Vault](#use-hashicorp-vault-to-manage-secrets)
- [AWS Secrets Manager](#use-aws-secrets-manager-to-manage-secrets)
- [GCP Secrets Manager](#use-gcp-secrets-manager-to-manage-secrets)
- [Kubernetes Secrets](#use-kubernetes-secrets-to-manage-secrets)

You can use APISIX Secret functions by specifying format variables in the consumer configuration or the plugin configuration of any plugin, as well as in SSL certificate configurations.

Expand Down Expand Up @@ -361,3 +362,112 @@ 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 |
| `ssl_verify` | boolean | No | `true` | Verify the Kubernetes API server TLS certificate |

### 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
the Kubernetes API, so secret rotations in Kubernetes are picked up automatically
without restarting APISIX.
Loading
Loading