From f5bae55d385795e8454ed6ec3a50439015df46ee Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 11 Jun 2026 18:02:26 +0800 Subject: [PATCH 1/3] fix(admin): reject duplicate consumer auth keys at write time --- apisix/admin/consumers.lua | 8 ++ apisix/admin/credentials.lua | 14 +- apisix/admin/resource.lua | 7 +- apisix/consumer.lua | 149 ++++++++++++++++++++ t/admin/consumers.t | 261 ++++++++++++++++++++++++++++++++++ t/admin/credentials.t | 266 ++++++++++++++++++++++++++++++++++- 6 files changed, 699 insertions(+), 6 deletions(-) diff --git a/apisix/admin/consumers.lua b/apisix/admin/consumers.lua index 32b1a9440420..dee37227e809 100644 --- a/apisix/admin/consumers.lua +++ b/apisix/admin/consumers.lua @@ -18,6 +18,7 @@ local core = require("apisix.core") local plugins = require("apisix.admin.plugins") local plugins_encrypt_conf = require("apisix.admin.plugins").encrypt_conf local resource = require("apisix.admin.resource") +local apisix_consumer = require("apisix.consumer") local function check_conf(username, conf, need_username, schema, opts) @@ -36,6 +37,13 @@ local function check_conf(username, conf, need_username, schema, opts) if not ok then return nil, {error_msg = "invalid plugins configuration: " .. err} end + + if not opts.skip_references_check then + ok, err = apisix_consumer.check_duplicate_key(conf.plugins, conf.username) + if not ok then + return nil, {error_msg = err} + end + end end if conf.group_id and not opts.skip_references_check then diff --git a/apisix/admin/credentials.lua b/apisix/admin/credentials.lua index d15b78ecac24..d8240b468d36 100644 --- a/apisix/admin/credentials.lua +++ b/apisix/admin/credentials.lua @@ -18,10 +18,12 @@ local core = require("apisix.core") local plugins = require("apisix.admin.plugins") local plugin = require("apisix.plugin") local resource = require("apisix.admin.resource") +local apisix_consumer = require("apisix.consumer") local plugins_encrypt_conf = require("apisix.admin.plugins").encrypt_conf local pairs = pairs -local function check_conf(_id, conf, _need_id, schema) +local function check_conf(id, conf, _need_id, schema, opts) + opts = opts or {} local ok, err = core.schema.check(schema, conf) if not ok then return nil, {error_msg = "invalid configuration: " .. err} @@ -42,6 +44,16 @@ local function check_conf(_id, conf, _need_id, schema) return nil, {error_msg = "only supports auth type plugins in consumer credential"} end end + + -- opts.sub_path is in the form of {consumer_name}/credentials or + -- {consumer_name}/credentials/{credential_id} + if not opts.skip_references_check and opts.sub_path then + local consumer_name = core.utils.split_uri(opts.sub_path)[1] + ok, err = apisix_consumer.check_duplicate_key(conf.plugins, consumer_name, id) + if not ok then + return nil, {error_msg = err} + end + end end return true, nil diff --git a/apisix/admin/resource.lua b/apisix/admin/resource.lua index 87c5692f6076..d002f765c0f6 100644 --- a/apisix/admin/resource.lua +++ b/apisix/admin/resource.lua @@ -81,7 +81,7 @@ local function check_forbidden_properties(conf, forbidden_properties) end -function _M:check_conf(id, conf, need_id, typ, allow_time) +function _M:check_conf(id, conf, need_id, typ, allow_time, sub_path) if self.name == "secrets" then id = typ .. "/" .. id end @@ -123,7 +123,8 @@ function _M:check_conf(id, conf, need_id, typ, allow_time) end local conf_for_check = tbl_deepcopy(conf) - local ok, err = self.checker(id, conf_for_check, need_id, self.schema, {secret_type = typ}) + local ok, err = self.checker(id, conf_for_check, need_id, self.schema, + {secret_type = typ, sub_path = sub_path}) if self.encrypt_conf then self.encrypt_conf(id, conf) @@ -253,7 +254,7 @@ function _M:put(id, conf, sub_path, args) end local need_id = not no_id_res[self.name] - local ok, err = self:check_conf(id, conf, need_id, typ) + local ok, err = self:check_conf(id, conf, need_id, typ, nil, sub_path) if not ok then return 400, err end diff --git a/apisix/consumer.lua b/apisix/consumer.lua index cd60a0209ff0..0068c5361034 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -24,6 +24,7 @@ local error = error local ipairs = ipairs local pairs = pairs local type = type +local tostring = tostring local string_sub = string.sub local consumers @@ -279,6 +280,154 @@ function _M.find_consumer(plugin_name, key, key_value) end +-- The auth plugins below match the consumer by a unique key attribute, which is +-- the attribute that find_consumer() is called with at runtime. Duplicating such +-- keys across consumers/credentials makes the runtime matching ambiguous: the +-- last loaded consumer silently wins. +local plugin_unique_key_attrs = { + ["key-auth"] = "key", + ["basic-auth"] = "username", + ["jwt-auth"] = "key", + ["hmac-auth"] = "key_id", +} + + +local function get_auth_unique_keys(plugins_conf) + local keys + for plugin_name, plugin_conf in pairs(plugins_conf) do + local key_attr = plugin_unique_key_attrs[plugin_name] + if key_attr and type(plugin_conf) == "table" then + local key_value = plugin_conf[key_attr] + if type(key_value) == "string" then + if secret.is_secret_ref(key_value) then + core.log.info("skip duplicate check for the ", key_attr, + " of plugin ", plugin_name, + ": secret reference can not be resolved at write time") + else + keys = keys or {} + keys[plugin_name] = key_value + end + end + end + end + + return keys +end + + +local function get_stored_unique_key(stored_plugins, plugin_name, key_attr) + local stored_conf = stored_plugins[plugin_name] + if type(stored_conf) ~= "table" then + return nil + end + + -- the stored conf may contain encrypted fields (data_encryption), + -- decrypt a copy of it like the Admin API GET handler does + local conf = core.table.deepcopy(stored_conf) + plugin.decrypt_conf(plugin_name, conf, core.schema.TYPE_CONSUMER) + + local key_value = conf[key_attr] + if type(key_value) ~= "string" or secret.is_secret_ref(key_value) then + return nil + end + + return key_value +end + + +-- Reject the write when an auth plugin key in plugins_conf is already used by +-- another consumer or credential, since the runtime consumer matching can not +-- distinguish them. +-- When writing a consumer, consumer_name is its username and credential_id is +-- nil: entries owned by the consumer itself are not treated as duplicates. +-- When writing a credential, both consumer_name and credential_id are set: +-- only the credential itself is skipped. +function _M.check_duplicate_key(plugins_conf, consumer_name, credential_id) + if not plugins_conf then + return true + end + + -- the credential id may come from the request payload as a number + if credential_id then + credential_id = tostring(credential_id) + end + + local in_keys = get_auth_unique_keys(plugins_conf) + if not in_keys then + return true + end + + local res, err = core.etcd.get("/consumers", true) + if not res then + return nil, "failed to list consumers: " .. err + end + + if res.status == 404 then + return true + end + + if res.status ~= 200 then + return nil, "failed to list consumers, response code: " .. res.status + end + + -- admin_api_version v3 puts the entries in body.list, + -- while v2 keeps them in body.node.nodes + local items = res.body.list + if not items and res.body.node then + items = res.body.node.nodes + end + + for _, item in ipairs(items or {}) do + if type(item) ~= "table" or type(item.value) ~= "table" + or type(item.value.plugins) ~= "table" then + goto CONTINUE + end + + local item_consumer_name, item_credential_id + if is_credential_etcd_key(item.key) then + item_consumer_name = get_consumer_name_from_credential_etcd_key(item.key) + item_credential_id = get_credential_id_from_etcd_key(item.key) + else + item_consumer_name = item.value.username + end + + if credential_id then + -- a credential does not conflict with itself + if item_consumer_name == consumer_name + and item_credential_id == credential_id then + goto CONTINUE + end + else + -- a consumer does not conflict with itself or its own credentials + if item_consumer_name == consumer_name then + goto CONTINUE + end + end + + for plugin_name, key_value in pairs(in_keys) do + local key_attr = plugin_unique_key_attrs[plugin_name] + local stored_value = get_stored_unique_key(item.value.plugins, + plugin_name, key_attr) + if stored_value == key_value then + local owner + if item_credential_id then + owner = "credential: " .. item_credential_id .. + " of consumer: " .. item_consumer_name + else + owner = "consumer: " .. item_consumer_name + end + return nil, "duplicate " .. key_attr .. " of plugin " .. plugin_name + .. " found with " .. owner + end + end + + ::CONTINUE:: + end + + return true +end + + local function check_consumer(consumer, key) local data_valid local err diff --git a/t/admin/consumers.t b/t/admin/consumers.t index 2e9c5e206bfc..c17cf26c6e69 100644 --- a/t/admin/consumers.t +++ b/t/admin/consumers.t @@ -360,3 +360,264 @@ GET /t --- error_code: 400 --- response_body eval qr/\{"error_msg":"the property is forbidden:.*"\}/ + + + +=== TEST 12: add consumer case_a with key-auth key +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "case_a", + "plugins": { + "key-auth": { + "key": "duplicate-check-key-1" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 13: add consumer case_b with the same key-auth key, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "case_b", + "plugins": { + "key-auth": { + "key": "duplicate-check-key-1" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with consumer: case_a"} + + + +=== TEST 14: add consumer case_b with a different key-auth key, should success +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "case_b", + "plugins": { + "key-auth": { + "key": "duplicate-check-key-2" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 15: update consumer case_a with its own key unchanged, should success +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "case_a", + "desc": "updated description", + "plugins": { + "key-auth": { + "key": "duplicate-check-key-1" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 16: duplicate basic-auth username between consumers, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "case_c", + "plugins": { + "basic-auth": { + "username": "case-user", + "password": "the-password" + } + } + }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "case_d", + "plugins": { + "basic-auth": { + "username": "case-user", + "password": "another-password" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate username of plugin basic-auth found with consumer: case_c"} + + + +=== TEST 17: store consumer with data_encryption explicitly enabled +--- extra_yaml_config +apisix: + data_encryption: + enable_encrypt_fields: true + keyring: + - qeddd145sfvddff3 +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "enc_case_a", + "plugins": { + "key-auth": { + "key": "duplicate-check-enc-key" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 18: duplicate key against the stored encrypted consumer, should fail +--- extra_yaml_config +apisix: + data_encryption: + enable_encrypt_fields: true + keyring: + - qeddd145sfvddff3 +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "enc_case_b", + "plugins": { + "key-auth": { + "key": "duplicate-check-enc-key" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with consumer: enc_case_a"} + + + +=== TEST 19: clean up consumers +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, name in ipairs({"case_a", "case_b", "case_c", "enc_case_a"}) do + local code, body = t('/apisix/admin/consumers/' .. name, ngx.HTTP_DELETE) + if code >= 300 then + ngx.say("failed to delete consumer ", name, ": ", body) + return + end + end + ngx.say("passed") + } + } +--- request +GET /t +--- response_body +passed diff --git a/t/admin/credentials.t b/t/admin/credentials.t index 15119829c2e3..50953e9ab3ef 100644 --- a/t/admin/credentials.t +++ b/t/admin/credentials.t @@ -110,7 +110,7 @@ passed "desc": "basic-auth for jack", "plugins": { "basic-auth": { - "username": "the-user", + "username": "the-new-user", "password": "the-password" } } @@ -119,7 +119,7 @@ passed "value":{ "desc":"basic-auth for jack", "id":"credential_a", - "plugins":{"basic-auth":{"username":"the-user","password":"WvF5kpaLvIzjuk4GNIMTJg=="}} + "plugins":{"basic-auth":{"username":"the-new-user","password":"WvF5kpaLvIzjuk4GNIMTJg=="}} }, "key":"/apisix/consumers/jack/credentials/credential_a" }]] @@ -492,3 +492,265 @@ GET /t --- error_code: 400 --- response_body {"error_msg":"missing credential id"} + + + +=== TEST 17: prepare consumers for duplicate key checks +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ "username": "alex" }]] + ) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "bob", + "plugins": { + "key-auth": { + "key": "the-key-of-bob" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 18: create a credential with key-auth for consumer alex +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/alex/credentials/cred-alex-a', + ngx.HTTP_PUT, + [[{ + "plugins": { + "key-auth": { + "key": "the-key-of-alex" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 19: credential of another consumer duplicates the credential key, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/bob/credentials/cred-bob-a', + ngx.HTTP_PUT, + [[{ + "plugins": { + "key-auth": { + "key": "the-key-of-alex" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with credential: cred-alex-a of consumer: alex"} + + + +=== TEST 20: credential duplicates the key of another consumer, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/alex/credentials/cred-alex-b', + ngx.HTTP_PUT, + [[{ + "plugins": { + "key-auth": { + "key": "the-key-of-bob" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with consumer: bob"} + + + +=== TEST 21: consumer duplicates the key of an existing credential, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "carol", + "plugins": { + "key-auth": { + "key": "the-key-of-alex" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with credential: cred-alex-a of consumer: alex"} + + + +=== TEST 22: update the credential with its own key unchanged, should success +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/alex/credentials/cred-alex-a', + ngx.HTTP_PUT, + [[{ + "desc": "new description", + "plugins": { + "key-auth": { + "key": "the-key-of-alex" + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 23: sibling credential of the same consumer duplicates the key, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/alex/credentials/cred-alex-c', + ngx.HTTP_PUT, + [[{ + "plugins": { + "key-auth": { + "key": "the-key-of-alex" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with credential: cred-alex-a of consumer: alex"} + + + +=== TEST 24: credential duplicates the key of its own consumer, should fail +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/bob/credentials/cred-bob-b', + ngx.HTTP_PUT, + [[{ + "plugins": { + "key-auth": { + "key": "the-key-of-bob" + } + } + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"duplicate key of plugin key-auth found with consumer: bob"} + + + +=== TEST 25: clean up consumers and credentials +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, name in ipairs({"alex", "bob", "jack"}) do + local code, body = t('/apisix/admin/consumers/' .. name, ngx.HTTP_DELETE) + if code >= 300 then + ngx.say("failed to delete consumer ", name, ": ", body) + return + end + end + ngx.say("passed") + } + } +--- request +GET /t +--- response_body +passed From 1f09b0beb5530bd0ec84d0b7d01bfbcb5fbf0ed3 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 11 Jun 2026 21:29:21 +0800 Subject: [PATCH 2/3] fix(admin): use locally synced consumer data for duplicate key check --- apisix/consumer.lua | 123 +++++++++++++++----------------------------- t/admin/consumers.t | 4 ++ 2 files changed, 45 insertions(+), 82 deletions(-) diff --git a/apisix/consumer.lua b/apisix/consumer.lua index 0068c5361034..17be26104ce3 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -315,41 +315,33 @@ local function get_auth_unique_keys(plugins_conf) end -local function get_stored_unique_key(stored_plugins, plugin_name, key_attr) - local stored_conf = stored_plugins[plugin_name] - if type(stored_conf) ~= "table" then - return nil - end - - -- the stored conf may contain encrypted fields (data_encryption), - -- decrypt a copy of it like the Admin API GET handler does - local conf = core.table.deepcopy(stored_conf) - plugin.decrypt_conf(plugin_name, conf, core.schema.TYPE_CONSUMER) - - local key_value = conf[key_attr] - if type(key_value) ~= "string" or secret.is_secret_ref(key_value) then - return nil - end - - return key_value -end - - -- Reject the write when an auth plugin key in plugins_conf is already used by -- another consumer or credential, since the runtime consumer matching can not -- distinguish them. +-- +-- The lookup goes through find_consumer(), i.e. the consumer data that this +-- process has already watched and synced from etcd -- the same view the +-- runtime uses for auth matching -- instead of pulling the full /consumers +-- range from etcd on every admin write. The tradeoff is that the local view +-- may lag the latest writes by one sync cycle, so a duplicate written moments +-- earlier can slip through under rapid successive or concurrent writes. This +-- is accepted: the check is best-effort protection against misconfiguration, +-- and the authoritative behavior at runtime remains last-loaded-wins. +-- -- When writing a consumer, consumer_name is its username and credential_id is --- nil: entries owned by the consumer itself are not treated as duplicates. --- When writing a credential, both consumer_name and credential_id are set: --- only the credential itself is skipped. +-- nil: a key owned by the consumer itself (inline or by its credentials) is +-- not treated as a duplicate. When writing a credential, both consumer_name +-- and credential_id are set: only the credential itself is skipped. function _M.check_duplicate_key(plugins_conf, consumer_name, credential_id) if not plugins_conf then return true end - -- the credential id may come from the request payload as a number - if credential_id then - credential_id = tostring(credential_id) + -- the /consumers watcher may not be initialized yet, e.g. the write + -- arrives right after the worker starts: degrade to allow, consistent + -- with the best-effort nature of this check + if not consumers then + return true end local in_keys = get_auth_unique_keys(plugins_conf) @@ -357,71 +349,38 @@ function _M.check_duplicate_key(plugins_conf, consumer_name, credential_id) return true end - local res, err = core.etcd.get("/consumers", true) - if not res then - return nil, "failed to list consumers: " .. err - end - - if res.status == 404 then - return true - end - - if res.status ~= 200 then - return nil, "failed to list consumers, response code: " .. res.status - end - - -- admin_api_version v3 puts the entries in body.list, - -- while v2 keeps them in body.node.nodes - local items = res.body.list - if not items and res.body.node then - items = res.body.node.nodes + -- the credential id may come from the request payload as a number, while + -- the cached credential_id is taken from the etcd key as a string + if credential_id then + credential_id = tostring(credential_id) end - for _, item in ipairs(items or {}) do - if type(item) ~= "table" or type(item.value) ~= "table" - or type(item.value.plugins) ~= "table" then - goto CONTINUE - end - - local item_consumer_name, item_credential_id - if is_credential_etcd_key(item.key) then - item_consumer_name = get_consumer_name_from_credential_etcd_key(item.key) - item_credential_id = get_credential_id_from_etcd_key(item.key) - else - item_consumer_name = item.value.username - end - - if credential_id then - -- a credential does not conflict with itself - if item_consumer_name == consumer_name - and item_credential_id == credential_id then - goto CONTINUE - end - else - -- a consumer does not conflict with itself or its own credentials - if item_consumer_name == consumer_name then - goto CONTINUE + for plugin_name, key_value in pairs(in_keys) do + local key_attr = plugin_unique_key_attrs[plugin_name] + local owner = _M.find_consumer(plugin_name, key_attr, key_value) + if owner then + local is_self + if credential_id then + -- a credential does not conflict with itself + is_self = owner.consumer_name == consumer_name + and owner.credential_id == credential_id + else + -- a consumer does not conflict with itself or its own credentials + is_self = owner.consumer_name == consumer_name end - end - for plugin_name, key_value in pairs(in_keys) do - local key_attr = plugin_unique_key_attrs[plugin_name] - local stored_value = get_stored_unique_key(item.value.plugins, - plugin_name, key_attr) - if stored_value == key_value then - local owner - if item_credential_id then - owner = "credential: " .. item_credential_id .. - " of consumer: " .. item_consumer_name + if not is_self then + local owner_desc + if owner.credential_id then + owner_desc = "credential: " .. owner.credential_id .. + " of consumer: " .. owner.consumer_name else - owner = "consumer: " .. item_consumer_name + owner_desc = "consumer: " .. owner.consumer_name end return nil, "duplicate " .. key_attr .. " of plugin " .. plugin_name - .. " found with " .. owner + .. " found with " .. owner_desc end end - - ::CONTINUE:: end return true diff --git a/t/admin/consumers.t b/t/admin/consumers.t index c17cf26c6e69..6a5fba171c91 100644 --- a/t/admin/consumers.t +++ b/t/admin/consumers.t @@ -506,6 +506,10 @@ passed return end + -- wait for the watcher to sync the previous write, as the + -- duplicate check runs against the locally synced consumer data + ngx.sleep(0.5) + local code, body = t('/apisix/admin/consumers', ngx.HTTP_PUT, [[{ From 414b669884e5e83a15e1f1408303ea28f106144a Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 11 Jun 2026 23:16:43 +0800 Subject: [PATCH 3/3] chore: wording fix from review --- apisix/consumer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apisix/consumer.lua b/apisix/consumer.lua index 17be26104ce3..86a27ca2fe9b 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -302,7 +302,7 @@ local function get_auth_unique_keys(plugins_conf) if secret.is_secret_ref(key_value) then core.log.info("skip duplicate check for the ", key_attr, " of plugin ", plugin_name, - ": secret reference can not be resolved at write time") + ": secret reference cannot be resolved at write time") else keys = keys or {} keys[plugin_name] = key_value