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

Reply via email to