This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new c7b5618e0 feat(proxy-cache): add consumer_isolation and
cache_set_cookie options (#13350)
c7b5618e0 is described below
commit c7b5618e0f8593e75522f0e460802339171c4ba7
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Thu May 14 11:29:45 2026 +0800
feat(proxy-cache): add consumer_isolation and cache_set_cookie options
(#13350)
---
apisix/plugins/proxy-cache/init.lua | 43 ++++
apisix/plugins/proxy-cache/memory_handler.lua | 15 +-
docs/en/latest/plugins/proxy-cache.md | 10 +
docs/zh/latest/plugins/proxy-cache.md | 10 +
t/plugin/proxy-cache/memory.t | 356 ++++++++++++++++++++++++++
5 files changed, 433 insertions(+), 1 deletion(-)
diff --git a/apisix/plugins/proxy-cache/init.lua
b/apisix/plugins/proxy-cache/init.lua
index 918f75599..3ccacf7cd 100644
--- a/apisix/plugins/proxy-cache/init.lua
+++ b/apisix/plugins/proxy-cache/init.lua
@@ -27,6 +27,26 @@ local STRATEGY_DISK = "disk"
local STRATEGY_MEMORY = "memory"
local DEFAULT_CACHE_ZONE = "disk_cache_one"
+-- Cache-key entries that already include the request identity. When any of
+-- these is present in the user-supplied cache_key, consumer_isolation is a
+-- no-op because the operator has opted into their own scheme.
+local IDENTITY_VARS = {
+ ["$consumer_name"] = true,
+ ["$consumer_group_id"] = true,
+ ["$remote_user"] = true,
+ ["$http_authorization"] = true,
+}
+
+
+local function cache_key_has_identity(cache_key)
+ for _, entry in ipairs(cache_key) do
+ if IDENTITY_VARS[entry] then
+ return true
+ end
+ end
+ return false
+end
+
local schema = {
type = "object",
properties = {
@@ -103,6 +123,14 @@ local schema = {
minimum = 1,
default = 300,
},
+ consumer_isolation = {
+ type = "boolean",
+ default = true,
+ },
+ cache_set_cookie = {
+ type = "boolean",
+ default = false,
+ },
},
}
@@ -158,6 +186,21 @@ function _M.access(conf, ctx)
core.log.info("proxy-cache plugin access phase, conf: ",
core.json.delay_encode(conf))
local value = util.generate_complex_value(conf.cache_key, ctx)
+
+ -- When an authenticated identity is available, prepend it to the effective
+ -- cache key so that each consumer gets its own cache namespace. The
control
+ -- character separator is outside the character set permitted by the
+ -- consumer username schema, keeping the prefix unambiguous.
+ if conf.consumer_isolation and not cache_key_has_identity(conf.cache_key)
then
+ local identity = ctx.consumer_name
+ if not identity or identity == "" then
+ identity = ctx.var.remote_user
+ end
+ if identity and identity ~= "" then
+ value = identity .. "\1" .. value
+ end
+ end
+
ctx.var.upstream_cache_key = value
core.log.info("proxy-cache cache key value:", value)
diff --git a/apisix/plugins/proxy-cache/memory_handler.lua
b/apisix/plugins/proxy-cache/memory_handler.lua
index e41cb72ea..22d59c5b0 100644
--- a/apisix/plugins/proxy-cache/memory_handler.lua
+++ b/apisix/plugins/proxy-cache/memory_handler.lua
@@ -170,7 +170,11 @@ local function cacheable_response(conf, ctx, cc)
end
end
- if conf.cache_control and (cc["private"] or cc["no-store"] or
cc["no-cache"]) then
+ -- Always honor upstream Cache-Control directives that mark the response as
+ -- non-shared/non-storable, regardless of the conf.cache_control flag. The
+ -- flag governs request-side semantics; upstream response directives are a
+ -- safety contract the application uses to mark personalized content.
+ if cc["private"] or cc["no-store"] or cc["no-cache"] then
return false
end
@@ -178,6 +182,15 @@ local function cacheable_response(conf, ctx, cc)
return false
end
+ -- Set-Cookie is per-recipient and not safe for a shared cache to store by
+ -- default; require explicit opt-in via cache_set_cookie.
+ if not conf.cache_set_cookie then
+ local set_cookie = ctx.var.upstream_http_set_cookie
+ if set_cookie and set_cookie ~= "" then
+ return false
+ end
+ end
+
return true
end
diff --git a/docs/en/latest/plugins/proxy-cache.md
b/docs/en/latest/plugins/proxy-cache.md
index ee20144f2..263fd9ee4 100644
--- a/docs/en/latest/plugins/proxy-cache.md
+++ b/docs/en/latest/plugins/proxy-cache.md
@@ -53,6 +53,16 @@ Responses can be conditionally cached based on request HTTP
methods, response st
| cache_control | boolean | False | false |
| If true, comply with `Cache-Control` behavior in the
HTTP specification. Only valid for in-memory strategy. |
| no_cache | array[string] | False | |
| One or more parameters to parse value from, such that
if any of the values is not empty and is not equal to `0`, response will not be
cached. Support [NGINX variables](https://nginx.org/en/docs/varindex.html) and
constant strings in values. Variables should be prefixed with a `$` sign.
|
| cache_ttl | integer | False | 300 | >=1
| Cache time to live (TTL) in seconds when caching in memory. To adjust
the TTL when caching on disk, update `cache_ttl` in the [configuration
files](#static-configurations). The TTL value is evaluated in conjunction with
the values in the response headers
[`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
and [`Expires`](https://developer.mozilla.org/en-US/docs/ [...]
+| consumer_isolation | boolean | False | true |
| If true, partition the cache by authenticated
identity. When the request resolves to an APISIX consumer (`ctx.consumer_name`)
or carries a remote user (`ctx.var.remote_user`), the identity is prepended to
the effective cache key so each consumer gets its own cache namespace. Has no
effect when `cache_key` already contains an identity-bearing variable
(`$consumer_name`, `$consumer_grou [...]
+| cache_set_cookie | boolean | False | false |
| If true, cache responses that include a `Set-Cookie`
header. Off by default because `Set-Cookie` is per-recipient and not safe for a
shared cache to store. Only valid for in-memory strategy — the on-disk strategy
never caches responses with `Set-Cookie` (NGINX's native `proxy_cache` enforces
this and does not honor this flag). Enable this only for routes where the
upstream's `Set-Cook [...]
+
+The plugin always honors upstream `Cache-Control: private`, `no-store`, and
`no-cache` directives — responses carrying any of these are not cached,
regardless of the `cache_control` flag. The `cache_control` flag governs
request-side semantics (client `Cache-Control` request directives such as
`max-age` and `min-fresh`) and TTL derivation from `max-age` / `s-maxage`; it
does not control whether upstream non-cacheability directives are respected.
+
+:::note
+
+The in-memory caching strategy does not honor the `Vary` response header on
cache lookup. If your upstream emits `Vary: X` and you want APISIX to partition
cache entries by `X`, include `$http_x` in `cache_key` explicitly, or use
`cache_strategy: disk` (NGINX's native cache honors `Vary` correctly).
+
+:::
## Static Configurations
diff --git a/docs/zh/latest/plugins/proxy-cache.md
b/docs/zh/latest/plugins/proxy-cache.md
index 44a8e5855..3ddf230c2 100644
--- a/docs/zh/latest/plugins/proxy-cache.md
+++ b/docs/zh/latest/plugins/proxy-cache.md
@@ -53,6 +53,16 @@ import TabItem from '@theme/TabItem';
| cache_control | boolean | 否 | false | | 如果为 true,则遵守 HTTP 规范中的
`Cache-Control` 行为。仅对内存中策略有效。 |
| no_cache | array[string] | 否 | | | 用于解析值的一个或多个参数,如果任何值不为空且不等于 `0`,则不会缓存响应。支持
[NGINX 变量](https://nginx.org/en/docs/varindex.html) 和值中的常量字符串。变量应以 `$` 符号为前缀。 |
| cache_ttl | integer | 否 | 300 | >=1 | 在内存中缓存时的缓存生存时间 (TTL),以秒为单位。要调整在磁盘上缓存时的
TTL,请更新[配置文件](#static-configurations) 中的 `cache_ttl`。TTL 值与从上游服务收到的响应标头
[`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
和
[`Expires`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires)
中的值一起评估。|
+| consumer_isolation | boolean | 否 | true | | 如果为 true,按已认证身份对缓存进行分区。当请求解析为
APISIX 消费者(`ctx.consumer_name`)或携带 remote
user(`ctx.var.remote_user`)时,身份会被作为前缀加入有效的缓存键,使每个消费者拥有独立的缓存命名空间。当 `cache_key`
已包含身份相关变量(`$consumer_name`、`$consumer_group_id`、`$remote_user` 或
`$http_authorization`)时此选项不生效。如果希望不同消费者共享缓存(例如上游响应与请求方无关的路由),可设置为 `false`。 |
+| cache_set_cookie | boolean | 否 | false | | 如果为 true,缓存包含 `Set-Cookie`
响应头的响应。默认关闭,因为 `Set-Cookie` 是面向特定接收方的,不适合放入共享缓存。仅对内存缓存策略有效——磁盘缓存策略由 NGINX 原生
`proxy_cache` 处理,对带 `Set-Cookie` 的响应始终不缓存,且不受此选项影响。仅当上游的 `Set-Cookie`
与具体用户无关(例如 A/B 测试变体 cookie)时才启用。 |
+
+无论 `cache_control` 标志如何,本插件始终遵循上游响应中的 `Cache-Control: private`、`no-store` 与
`no-cache` 指令——携带其中任一指令的响应不会被缓存。`cache_control` 标志只控制请求侧语义(客户端的
`max-age`、`min-fresh` 等请求指令)以及基于 `max-age` / `s-maxage` 的 TTL
推导,不控制是否遵守上游的不可缓存指令。
+
+:::note
+
+内存缓存策略在缓存查找时不会处理 `Vary` 响应头。如果上游返回 `Vary: X` 并希望 APISIX 按 `X` 对缓存条目进行分区,请在
`cache_key` 中显式包含 `$http_x`,或使用 `cache_strategy: disk`(NGINX 原生缓存可以正确处理 `Vary`)。
+
+:::
## 静态配置
diff --git a/t/plugin/proxy-cache/memory.t b/t/plugin/proxy-cache/memory.t
index 1bdc21e0e..d9460ebce 100644
--- a/t/plugin/proxy-cache/memory.t
+++ b/t/plugin/proxy-cache/memory.t
@@ -48,6 +48,20 @@ add_block_preprocessor(sub {
listen 1986;
server_tokens off;
+ location = /profile {
+ content_by_lua_block {
+ local session = ngx.var.cookie_session or "anonymous"
+ ngx.header["Set-Cookie"] = "session=" .. session ..
"-refreshed; Path=/"
+ ngx.say("user=", session)
+ }
+ }
+
+ location = /me-cacheable {
+ content_by_lua_block {
+ ngx.say("hit-id=", ngx.now())
+ }
+ }
+
location / {
expires 60s;
@@ -704,3 +718,345 @@ GET /t
--- response_body_like
.*err: invalid or empty cache_zone for cache_strategy: memory.*
--- error_code: 400
+
+
+
+=== TEST 37: proxy-cache refuses to cache authenticated responses that set a
Set-Cookie
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local function setup(path, method, body)
+ local code, res_body = t(path, method, body)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(res_body)
+ return false
+ end
+
+ return true
+ end
+
+ if not setup('/apisix/admin/consumers', ngx.HTTP_PUT, [[{
+ "username": "cache_alice",
+ "plugins": {
+ "key-auth": {
+ "key": "alice-cache-key"
+ }
+ }
+ }]]) then
+ return
+ end
+
+ if not setup('/apisix/admin/consumers', ngx.HTTP_PUT, [[{
+ "username": "cache_bob",
+ "plugins": {
+ "key-auth": {
+ "key": "bob-cache-key"
+ }
+ }
+ }]]) then
+ return
+ end
+
+ if not
setup('/apisix/admin/routes/proxy-cache-consumer-isolation', ngx.HTTP_PUT, [[{
+ "uri": "/profile",
+ "plugins": {
+ "key-auth": {},
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]) then
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/profile"
+
+ local httpc = http.new()
+ local alice_res, err = httpc:request_uri(uri, {
+ headers = {
+ apikey = "alice-cache-key",
+ Cookie = "session=alice",
+ },
+ })
+ if not alice_res then
+ ngx.say(err)
+ return
+ end
+
+ httpc = http.new()
+ local bob_res, err = httpc:request_uri(uri, {
+ headers = {
+ apikey = "bob-cache-key",
+ Cookie = "session=bob",
+ },
+ })
+ if not bob_res then
+ ngx.say(err)
+ return
+ end
+
+ ngx.say("alice_status=", alice_res.status)
+ ngx.say("alice_cache=", alice_res.headers["Apisix-Cache-Status"])
+ ngx.say("alice_cookie=", alice_res.headers["Set-Cookie"])
+ ngx.say("alice_body=", alice_res.body)
+ ngx.say("bob_status=", bob_res.status)
+ ngx.say("bob_cache=", bob_res.headers["Apisix-Cache-Status"])
+ ngx.say("bob_cookie=", bob_res.headers["Set-Cookie"])
+ ngx.say("bob_body=", bob_res.body)
+ }
+ }
+--- request
+GET /t
+--- response_body_like eval
+qr/alice_status=200
+alice_cache=MISS
+alice_cookie=session=alice-refreshed; Path=\/
+alice_body=user=alice
+
+bob_status=200
+bob_cache=MISS
+bob_cookie=session=bob-refreshed; Path=\/
+bob_body=user=bob
+/
+
+
+
+=== TEST 38: proxy-cache refuses to cache responses with Set-Cookie by default
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ -- Drop the previous test's auth-protected /profile route so this
+ -- test sees an unauthenticated route on the same URI.
+ t('/apisix/admin/routes/proxy-cache-consumer-isolation',
ngx.HTTP_DELETE)
+
+ local code, body =
t('/apisix/admin/routes/proxy-cache-set-cookie', ngx.HTTP_PUT, [[{
+ "uri": "/profile",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/profile"
+
+ local first, err = http.new():request_uri(uri, { headers = {
Cookie = "session=alice" } })
+ if not first then
+ ngx.say("first failed: ", err)
+ return
+ end
+
+ local second, err = http.new():request_uri(uri, { headers = {
Cookie = "session=bob" } })
+ if not second then
+ ngx.say("second failed: ", err)
+ return
+ end
+
+ ngx.say("first_cache=", first.headers["Apisix-Cache-Status"])
+ ngx.say("first_body=", first.body)
+ ngx.say("second_cache=", second.headers["Apisix-Cache-Status"])
+ ngx.say("second_body=", second.body)
+ }
+ }
+--- request
+GET /t
+--- response_body_like eval
+qr/first_cache=MISS
+first_body=user=alice
+
+second_cache=MISS
+second_body=user=bob/
+
+
+
+=== TEST 39: proxy-cache honors upstream Cache-Control: private regardless of
cache_control flag
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body = t('/apisix/admin/routes/proxy-cache-private',
ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri", "$arg_cc"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/hello?cc=private"
+
+ local first, err = http.new():request_uri(uri)
+ if not first then
+ ngx.say("first failed: ", err)
+ return
+ end
+
+ local second, err = http.new():request_uri(uri)
+ if not second then
+ ngx.say("second failed: ", err)
+ return
+ end
+
+ ngx.say("first_cache=", first.headers["Apisix-Cache-Status"])
+ ngx.say("second_cache=", second.headers["Apisix-Cache-Status"])
+ }
+ }
+--- request
+GET /t
+--- response_body
+first_cache=MISS
+second_cache=MISS
+
+
+
+=== TEST 40: consumer_isolation partitions the cache key by consumer
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local function setup(path, method, body)
+ local code, res_body = t(path, method, body)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(res_body)
+ return false
+ end
+ return true
+ end
+
+ if not setup('/apisix/admin/consumers', ngx.HTTP_PUT, [[{
+ "username": "cache_alice",
+ "plugins": {
+ "key-auth": {
+ "key": "alice-cache-key"
+ }
+ }
+ }]]) then
+ return
+ end
+
+ if not setup('/apisix/admin/consumers', ngx.HTTP_PUT, [[{
+ "username": "cache_bob",
+ "plugins": {
+ "key-auth": {
+ "key": "bob-cache-key"
+ }
+ }
+ }]]) then
+ return
+ end
+
+ if not setup('/apisix/admin/routes/proxy-cache-isolation',
ngx.HTTP_PUT, [[{
+ "uri": "/me-cacheable",
+ "plugins": {
+ "key-auth": {},
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]) then
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/me-cacheable"
+
+ local function fetch(apikey)
+ local res, err = http.new():request_uri(uri, {
+ headers = { apikey = apikey },
+ })
+ if not res then
+ return nil, err
+ end
+ return res.headers["Apisix-Cache-Status"]
+ end
+
+ local alice_1, err = fetch("alice-cache-key")
+ if not alice_1 then ngx.say("alice_1 failed: ", err) return end
+
+ local alice_2, err = fetch("alice-cache-key")
+ if not alice_2 then ngx.say("alice_2 failed: ", err) return end
+
+ local bob_1, err = fetch("bob-cache-key")
+ if not bob_1 then ngx.say("bob_1 failed: ", err) return end
+
+ local bob_2, err = fetch("bob-cache-key")
+ if not bob_2 then ngx.say("bob_2 failed: ", err) return end
+
+ ngx.say("alice_1=", alice_1)
+ ngx.say("alice_2=", alice_2)
+ ngx.say("bob_1=", bob_1)
+ ngx.say("bob_2=", bob_2)
+ }
+ }
+--- request
+GET /t
+--- response_body
+alice_1=MISS
+alice_2=HIT
+bob_1=MISS
+bob_2=HIT