This is an automated email from the ASF dual-hosted git repository.

AlinsRan 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 9f40d6c2a feat: add acl plugin (#13349)
9f40d6c2a is described below

commit 9f40d6c2a60daf6ee825ec76a90f4ee3a245a47f
Author: AlinsRan <[email protected]>
AuthorDate: Wed May 20 08:52:24 2026 +0800

    feat: add acl plugin (#13349)
---
 apisix/cli/config.lua         |    1 +
 apisix/plugins/acl.lua        |  251 +++++++
 conf/config.yaml.example      |    1 +
 docs/en/latest/config.json    |    1 +
 docs/en/latest/plugins/acl.md |  241 +++++++
 docs/zh/latest/config.json    |    1 +
 docs/zh/latest/plugins/acl.md |  241 +++++++
 t/admin/plugins.t             |    1 +
 t/plugin/acl.t                | 1539 +++++++++++++++++++++++++++++++++++++++++
 t/plugin/acl2.t               |  115 +++
 10 files changed, 2392 insertions(+)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index c42ecbdee..158d2d602 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -221,6 +221,7 @@ local _M = {
     "jwt-auth",
     "jwe-decrypt",
     "key-auth",
+    "acl",
     "consumer-restriction",
     "attach-consumer-label",
     "forward-auth",
diff --git a/apisix/plugins/acl.lua b/apisix/plugins/acl.lua
new file mode 100644
index 000000000..ba0c751e9
--- /dev/null
+++ b/apisix/plugins/acl.lua
@@ -0,0 +1,251 @@
+--
+-- 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.
+--
+local type      = type
+local ipairs    = ipairs
+local pairs     = pairs
+local jp        = require("jsonpath")
+local re_split  = require("ngx.re").split
+local core      = require("apisix.core")
+local schema = {
+    type = "object",
+    properties = {
+        external_user_label_field = {type = "string", default = "groups", 
minLength = 1},
+        external_user_label_field_key = {type = "string", minLength = 1},
+        external_user_label_field_parser = {
+            type = "string",
+            enum = {"segmented_text", "json", "table"},
+        },
+        external_user_label_field_separator = {
+            type = "string",
+            minLength = 1,
+            description = "The separator(regex) of the segmented_text parser",
+        },
+        allow_labels = {
+            type = "object",
+            minProperties = 1,
+            patternProperties = {
+                [".*"] = {
+                    type = "array",
+                    minItems = 1,
+                    items = {type = "string"}
+                },
+            },
+        },
+        deny_labels = {
+            type = "object",
+            minProperties = 1,
+            patternProperties = {
+                [".*"] = {
+                    type = "array",
+                    minItems = 1,
+                    items = {type = "string"}
+                },
+            },
+        },
+        rejected_code = {type = "integer", minimum = 200, default = 403},
+        rejected_msg = {type = "string"},
+    },
+    allOf = {
+        {
+            ["if"] = {
+                required = { "external_user_label_field_parser" },
+                properties = { external_user_label_field_parser = { const = 
"segmented_text" } },
+            },
+            ["then"] = {
+                required = { "external_user_label_field_separator" },
+            },
+        },
+    },
+    anyOf = {
+        {required = {"allow_labels"}},
+        {required = {"deny_labels"}}
+    },
+}
+
+local plugin_name = "acl"
+
+local _M = {
+    version = 0.1,
+    priority = 2410,
+    name = plugin_name,
+    schema = schema,
+}
+
+local parsers = {
+    SEGMENTED_TEXT = "segmented_text",
+    JSON = "json",
+    TABLE = "table",
+}
+
+
+local function extra_values_with_parser(value, parser, sep)
+    local values = {}
+    if parser == parsers.SEGMENTED_TEXT then
+        sep = "\\s*" .. sep .. "\\s*"
+        local res, err = re_split(value, sep, "jo")
+        if res then
+            return res
+        end
+        core.log.warn("failed to split labels [", value, "], err: ", err)
+
+        return values
+    end
+
+    local typ = type(value)
+
+    if parser == parsers.TABLE then
+        if typ == "table" then
+            return value
+        end
+        core.log.warn("the parser is specified as table, but the type of value 
is not table: ", typ)
+        return values
+    end
+
+    if parser == parsers.JSON then
+        if typ ~= "string" then
+            core.log.warn("the parser is specified as json array, but the 
value type is not string")
+            return values
+        end
+        if not core.string.has_prefix(value, "[") then
+            core.log.warn("the parser is specified as json array, ",
+                          "but the value do not has prefix '['")
+            return values
+        end
+
+        local res, err = core.json.decode(value)
+        if res then
+            return res
+        end
+        core.log.warn("failed to decode labels [", value, "] as array, err: ", 
err)
+        return values
+    end
+
+    return values
+end
+
+
+local function extra_values_without_parser(value)
+    local values = {}
+    local typ = type(value)
+
+    if typ == "table" then
+        return extra_values_with_parser(value, parsers.TABLE, "")
+    end
+
+    if typ == "string" then
+        if core.string.has_prefix(value, "[") then
+            return extra_values_with_parser(value, parsers.JSON, "")
+        end
+        if core.string.find(value, ",") then
+            return extra_values_with_parser(value, parsers.SEGMENTED_TEXT, ",")
+        end
+        core.log.info("the string value can not parsed by ", parsers.JSON,
+                      " or ",parsers.SEGMENTED_TEXT)
+        return { value }
+    end
+
+    core.log.error("unsupported type of label value: ", typ)
+    return values
+end
+
+
+local function contains_value(want_values, value, parser, sep)
+    local values
+    if parser then
+        values = extra_values_with_parser(value, parser, sep)
+    else
+        values = extra_values_without_parser(value)
+    end
+
+    for _, want in ipairs(want_values) do
+        for _, value in ipairs(values) do
+            if want == value then
+                return true
+            end
+        end
+    end
+    return false
+end
+
+
+local function contains_label(want_labels, labels, parser, sep)
+    if not labels then
+        return false
+    end
+    for key, values in pairs(want_labels) do
+        if labels[key] and contains_value(values, labels[key], parser, sep) 
then
+            return true
+        end
+    end
+    return false
+end
+
+local function reject(conf)
+    if conf.rejected_msg then
+        return conf.rejected_code , { message = conf.rejected_msg }
+    end
+    return conf.rejected_code , { message = "The consumer is forbidden."}
+end
+
+function _M.check_schema(conf)
+    local ok, err = core.schema.check(schema, conf)
+    if not ok then
+        return false, err
+    end
+
+    local _, parse_err = jp.parse(conf.external_user_label_field)
+    if parse_err then
+        return false, "invalid external_user_label_field: " .. parse_err
+    end
+
+    return true
+end
+
+function _M.access(conf, ctx)
+    local labels
+    local parser, sep
+    if ctx.consumer then
+        labels = ctx.consumer.labels
+    elseif ctx.external_user then
+        local label_key = conf.external_user_label_field
+        if conf.external_user_label_field_key then
+            label_key = conf.external_user_label_field_key
+        end
+        local label_value = jp.value(ctx.external_user, 
conf.external_user_label_field)
+        labels = { [label_key] = label_value }
+        parser = conf.external_user_label_field_parser
+        sep = conf.external_user_label_field_separator
+    else
+        return 401, { message = "Missing authentication."}
+    end
+
+    core.log.debug("consumer's or user's labels: ", 
core.json.delay_encode(labels))
+
+    if conf.deny_labels then
+        if contains_label(conf.deny_labels, labels, parser, sep) then
+            return reject(conf)
+        end
+    end
+
+    if conf.allow_labels then
+        if not contains_label(conf.allow_labels, labels, parser, sep) then
+            return reject(conf)
+        end
+    end
+end
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 6023c83bc..39493fb67 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -504,6 +504,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - jwt-auth                       # priority: 2510
   - jwe-decrypt                    # priority: 2509
   - key-auth                       # priority: 2500
+  - acl                            # priority: 2410
   - consumer-restriction           # priority: 2400
   - attach-consumer-label          # priority: 2399
   - forward-auth                   # priority: 2002
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index 115448b95..f44b65ee9 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -148,6 +148,7 @@
             "plugins/ua-restriction",
             "plugins/referer-restriction",
             "plugins/consumer-restriction",
+            "plugins/acl",
             "plugins/csrf",
             "plugins/public-api",
             "plugins/gm",
diff --git a/docs/en/latest/plugins/acl.md b/docs/en/latest/plugins/acl.md
new file mode 100644
index 000000000..edd89df2e
--- /dev/null
+++ b/docs/en/latest/plugins/acl.md
@@ -0,0 +1,241 @@
+---
+title: acl
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - acl
+description: The acl Plugin implements label-based access control for API 
routes, allowing or denying requests based on consumer labels or external user 
attributes.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/acl"; />
+</head>
+
+## Description
+
+The `acl` Plugin provides label-based access control for API routes. It checks 
consumer labels (from APISIX [Consumers](../terminology/consumer.md)) or 
external user attributes (from authentication plugins that set 
`ctx.external_user`) against configured allow or deny lists.
+
+The Plugin supports three label value formats:
+
+- **table**: the label value is a Lua table (array).
+- **json**: the label value is a JSON-encoded array string, e.g. 
`["admin","user"]`.
+- **segmented_text**: the label value is a delimiter-separated string, e.g. 
`admin,user`.
+
+At least one of `allow_labels` or `deny_labels` must be configured. When both 
are present, `deny_labels` is evaluated first.
+
+## Attributes
+
+| Name | Type | Required | Default | Valid values | Description |
+|------|------|----------|---------|--------------|-------------|
+| allow_labels | object | False* | | | Labels to allow. Keys are label names, 
values are arrays of allowed label values. At least one of `allow_labels` or 
`deny_labels` must be configured. |
+| deny_labels | object | False* | | | Labels to deny. Keys are label names, 
values are arrays of denied label values. At least one of `allow_labels` or 
`deny_labels` must be configured. |
+| rejected_code | integer | False | 403 | >= 200 | HTTP status code returned 
when the request is rejected. |
+| rejected_msg | string | False | | | Custom rejection message body. If not 
set, defaults to `{"message":"The consumer is forbidden."}`. |
+| external_user_label_field | string | False | `groups` | | JSONPath 
expression or plain field name used to extract the label value from 
`ctx.external_user`. For example, `$..groups` (JSONPath) or `groups` (plain 
field name). |
+| external_user_label_field_key | string | False | | | The label key name used 
for the extracted value. Defaults to the value of `external_user_label_field`. |
+| external_user_label_field_parser | string | False | | `segmented_text`, 
`json`, `table` | How to parse the extracted field value. If not set, the 
Plugin auto-detects the format. |
+| external_user_label_field_separator | string | False | | | Separator regex 
for the `segmented_text` parser. Required when 
`external_user_label_field_parser` is `segmented_text`. |
+
+## Examples
+
+The examples below demonstrate how you can configure the `acl` Plugin for 
different scenarios.
+
+:::note
+
+You can fetch the `admin_key` from `config.yaml` and save to an environment 
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+### Allow Consumers by Label
+
+The example below demonstrates how to use the `acl` Plugin with 
[`key-auth`](./key-auth.md) to allow only consumers that have a specific label 
value.
+
+Create a Consumer `alice` with a label `team: platform`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/consumers"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "username": "alice",
+    "plugins": {
+      "key-auth": {
+        "key": "alice-key"
+      }
+    },
+    "labels": {
+      "team": "platform"
+    }
+  }'
+```
+
+Create a second Consumer `bob` with a different label `team: sales`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/consumers"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "username": "bob",
+    "plugins": {
+      "key-auth": {
+        "key": "bob-key"
+      }
+    },
+    "labels": {
+      "team": "sales"
+    }
+  }'
+```
+
+Create a Route with `key-auth` and `acl` configured to allow only consumers 
with label `team: platform`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "acl-allow-route",
+    "uri": "/get",
+    "plugins": {
+      "key-auth": {},
+      "acl": {
+        "allow_labels": {
+          "team": ["platform"]
+        }
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+Send a request as `alice` (label `team: platform`):
+
+```shell
+curl "http://127.0.0.1:9080/get"; \
+  -H "apikey: alice-key"
+```
+
+You should receive an HTTP `200` response, as `alice` has the allowed label.
+
+Send a request as `bob` (label `team: sales`):
+
+```shell
+curl "http://127.0.0.1:9080/get"; \
+  -H "apikey: bob-key"
+```
+
+You should receive an HTTP `403` response, as `bob` does not have the allowed 
label.
+
+### Deny Consumers by Label
+
+The example below demonstrates how to block consumers based on a label value 
while allowing all others.
+
+Create a Consumer `carol` with label `role: guest`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/consumers"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "username": "carol",
+    "plugins": {
+      "key-auth": {
+        "key": "carol-key"
+      }
+    },
+    "labels": {
+      "role": "guest"
+    }
+  }'
+```
+
+Create a Route that denies consumers with label `role: guest`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "acl-deny-route",
+    "uri": "/get",
+    "plugins": {
+      "key-auth": {},
+      "acl": {
+        "deny_labels": {
+          "role": ["guest"]
+        }
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+Send a request as `carol`:
+
+```shell
+curl "http://127.0.0.1:9080/get"; \
+  -H "apikey: carol-key"
+```
+
+You should receive an HTTP `403` response.
+
+### Custom Rejection Code and Message
+
+You can customize the HTTP status code and message returned when access is 
denied.
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "acl-custom-reject-route",
+    "uri": "/get",
+    "plugins": {
+      "key-auth": {},
+      "acl": {
+        "allow_labels": {
+          "team": ["platform"]
+        },
+        "rejected_code": 401,
+        "rejected_msg": "Access denied: insufficient label permissions."
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+When a Consumer without the required label accesses the route, they receive a 
`401` response with the configured message.
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index 6499144f7..6c3713add 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -137,6 +137,7 @@
             "plugins/ua-restriction",
             "plugins/referer-restriction",
             "plugins/consumer-restriction",
+            "plugins/acl",
             "plugins/csrf",
             "plugins/public-api",
             "plugins/gm",
diff --git a/docs/zh/latest/plugins/acl.md b/docs/zh/latest/plugins/acl.md
new file mode 100644
index 000000000..6f5eb2540
--- /dev/null
+++ b/docs/zh/latest/plugins/acl.md
@@ -0,0 +1,241 @@
+---
+title: acl
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - 插件
+  - acl
+description: acl 插件基于标签实现访问控制,通过检查消费者标签或外部用户属性来允许或拒绝请求。
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/acl"; />
+</head>
+
+## 描述
+
+`acl` 插件为 API 路由提供基于标签的访问控制。它检查 APISIX 
[消费者](../terminology/consumer.md)的标签,或来自外部认证插件(设置了 
`ctx.external_user`)的用户属性,并与配置的允许列表或拒绝列表进行比对。
+
+插件支持三种标签值格式:
+
+- **table**:标签值为 Lua 表(数组)。
+- **json**:标签值为 JSON 编码的数组字符串,例如 `["admin","user"]`。
+- **segmented_text**:标签值为分隔符分隔的字符串,例如 `admin,user`。
+
+`allow_labels` 和 `deny_labels` 至少需配置其中一个。当两者同时存在时,先评估 `deny_labels`。
+
+## 属性
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
+|------|------|--------|--------|--------|------|
+| allow_labels | object | 否* | | | 允许的标签。键为标签名,值为允许的标签值数组。`allow_labels` 和 
`deny_labels` 至少需配置其中一个。 |
+| deny_labels | object | 否* | | | 拒绝的标签。键为标签名,值为拒绝的标签值数组。`allow_labels` 和 
`deny_labels` 至少需配置其中一个。 |
+| rejected_code | integer | 否 | 403 | >= 200 | 请求被拒绝时返回的 HTTP 状态码。 |
+| rejected_msg | string | 否 | | | 自定义拒绝消息体。若未设置,默认返回 `{"message":"The consumer 
is forbidden."}`。 |
+| external_user_label_field | string | 否 | `groups` | | 用于从 
`ctx.external_user` 提取标签值的 JSONPath 表达式或普通字段名称。例如,`$..groups`(JSONPath)或 
`groups`(字段名称)。 |
+| external_user_label_field_key | string | 否 | | | 提取值所使用的标签键名。默认为 
`external_user_label_field` 的值。 |
+| external_user_label_field_parser | string | 否 | | 
`segmented_text`、`json`、`table` | 提取字段值的解析方式。若未设置,插件自动检测格式。 |
+| external_user_label_field_separator | string | 否 | | | `segmented_text` 
解析器使用的分隔符(正则表达式)。当 `external_user_label_field_parser` 为 `segmented_text` 时必填。 |
+
+## 示例
+
+以下示例演示了如何为不同场景配置 `acl` 插件。
+
+:::note
+
+可以使用以下命令从 `config.yaml` 中获取 `admin_key` 并保存到环境变量:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+### 按标签允许消费者
+
+以下示例演示如何将 `acl` 插件与 [`key-auth`](./key-auth.md) 结合使用,仅允许具有特定标签值的消费者访问。
+
+创建消费者 `alice`,标签为 `team: platform`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/consumers"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "username": "alice",
+    "plugins": {
+      "key-auth": {
+        "key": "alice-key"
+      }
+    },
+    "labels": {
+      "team": "platform"
+    }
+  }'
+```
+
+创建第二个消费者 `bob`,标签为 `team: sales`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/consumers"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "username": "bob",
+    "plugins": {
+      "key-auth": {
+        "key": "bob-key"
+      }
+    },
+    "labels": {
+      "team": "sales"
+    }
+  }'
+```
+
+创建启用了 `key-auth` 和 `acl` 的路由,仅允许标签 `team: platform` 的消费者访问:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "acl-allow-route",
+    "uri": "/get",
+    "plugins": {
+      "key-auth": {},
+      "acl": {
+        "allow_labels": {
+          "team": ["platform"]
+        }
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+以 `alice`(标签 `team: platform`)的身份发送请求:
+
+```shell
+curl "http://127.0.0.1:9080/get"; \
+  -H "apikey: alice-key"
+```
+
+由于 `alice` 具有允许的标签,应收到 HTTP `200` 响应。
+
+以 `bob`(标签 `team: sales`)的身份发送请求:
+
+```shell
+curl "http://127.0.0.1:9080/get"; \
+  -H "apikey: bob-key"
+```
+
+由于 `bob` 不具备允许的标签,应收到 HTTP `403` 响应。
+
+### 按标签拒绝消费者
+
+以下示例演示如何基于标签值拒绝特定消费者,同时允许其他消费者访问。
+
+创建消费者 `carol`,标签为 `role: guest`:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/consumers"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "username": "carol",
+    "plugins": {
+      "key-auth": {
+        "key": "carol-key"
+      }
+    },
+    "labels": {
+      "role": "guest"
+    }
+  }'
+```
+
+创建路由,拒绝标签 `role: guest` 的消费者访问:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "acl-deny-route",
+    "uri": "/get",
+    "plugins": {
+      "key-auth": {},
+      "acl": {
+        "deny_labels": {
+          "role": ["guest"]
+        }
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+以 `carol` 的身份发送请求:
+
+```shell
+curl "http://127.0.0.1:9080/get"; \
+  -H "apikey: carol-key"
+```
+
+应收到 HTTP `403` 响应。
+
+### 自定义拒绝状态码和消息
+
+可以自定义访问被拒绝时返回的 HTTP 状态码和消息。
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "acl-custom-reject-route",
+    "uri": "/get",
+    "plugins": {
+      "key-auth": {},
+      "acl": {
+        "allow_labels": {
+          "team": ["platform"]
+        },
+        "rejected_code": 401,
+        "rejected_msg": "Access denied: insufficient label permissions."
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+当不具备所需标签的消费者访问该路由时,将收到 `401` 响应和配置的消息。
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index eea7505ca..e05926db0 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -87,6 +87,7 @@ basic-auth
 jwt-auth
 jwe-decrypt
 key-auth
+acl
 consumer-restriction
 attach-consumer-label
 forward-auth
diff --git a/t/plugin/acl.t b/t/plugin/acl.t
new file mode 100644
index 000000000..09de05d25
--- /dev/null
+++ b/t/plugin/acl.t
@@ -0,0 +1,1539 @@
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: add consumer jack
+--- 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": "jack",
+                    "plugins": {
+                        "basic-auth": {
+                            "username": "jack",
+                            "password": "123456"
+                        }
+                    },
+                    "labels": {
+                        "org": "apache",
+                        "project": "gateway,apisix,web-server"
+                    }
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: add consumer rose
+--- 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": "rose",
+                    "plugins": {
+                        "basic-auth": {
+                            "username": "rose",
+                            "password": "123456"
+                        }
+                    },
+                    "labels": {
+                        "org": "[\"opensource\",\"apache\"]",
+                        "project": 
"[\"tomcat\",\"web-server\",\"http,server\"]"
+                    }
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 3: set allow_labels
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "basic-auth": {},
+                            "acl": {
+                                 "allow_labels": {
+                                    "org": ["apache"]
+                                 }
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 4: verify unauthorized
+--- request
+GET /hello
+--- error_code: 401
+--- response_body
+{"message":"Missing authorization in request"}
+
+
+
+=== TEST 5: verify jack
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic amFjazoxMjM0NTY=
+--- response_body
+hello world
+
+
+
+=== TEST 6: verify rose
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic cm9zZToxMjM0NTY=
+--- response_body
+hello world
+
+
+
+=== TEST 7: set allow_labels
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "basic-auth": {},
+                            "acl": {
+                                 "allow_labels": {
+                                     "project": ["apisix"]
+                                 }
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 8: verify jack
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic amFjazoxMjM0NTY=
+--- response_body
+hello world
+
+
+
+=== TEST 9: verify rose
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic cm9zZToxMjM0NTY=
+--- error_code: 403
+--- response_body
+{"message":"The consumer is forbidden."}
+
+
+
+=== TEST 10: set deny_labels
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "basic-auth": {},
+                            "acl": {
+                                 "deny_labels": {
+                                     "project": ["apisix"]
+                                 },
+                                 "rejected_msg": "request is forbidden"
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 11: verify jack
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic amFjazoxMjM0NTY=
+--- error_code: 403
+--- response_body
+{"message":"request is forbidden"}
+
+
+
+=== TEST 12: verify rose
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic cm9zZToxMjM0NTY=
+--- response_body
+hello world
+
+
+
+=== TEST 13: set deny_labels with multiple values
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "basic-auth": {},
+                            "acl": {
+                                 "deny_labels": {
+                                     "project": ["apisix", "tomcat"]
+                                 }
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 14: verify jack
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic amFjazoxMjM0NTY=
+--- error_code: 403
+
+
+
+=== TEST 15: verify rose
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic cm9zZToxMjM0NTY=
+--- error_code: 403
+
+
+
+=== TEST 16: set allow_labels with comma
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "basic-auth": {},
+                            "acl": {
+                                 "allow_labels": {
+                                    "project": ["http,server"]
+                                 }
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 17: verify jack
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic amFjazoxMjM0NTY=
+--- error_code: 403
+
+
+
+=== TEST 18: verify rose
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic cm9zZToxMjM0NTY=
+--- response_body
+hello world
+
+
+
+=== TEST 19: test acl with external user
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                                "phase": "access",
+                                "functions" : ["return function(conf, ctx)
+                                            local core = 
require(\"apisix.core\");
+                                            local uri_args = 
core.request.get_uri_args(ctx) or {};
+                                            if type(uri_args.team) == 
\"table\" then ctx.external_user = { team = uri_args.team } else 
ctx.external_user = { team = { uri_args.team } } end;
+                                            end"]
+                            },
+                            "acl": {
+                                 "external_user_label_field": "team",
+                                 "allow_labels": {
+                                    "team": ["cloud","infra","devops","qa"]
+                                 }
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 20: verify infra team
+--- request
+GET /hello?team=infra
+--- response_body
+hello world
+
+
+
+=== TEST 21: verify infra & fake team
+--- request
+GET /hello?team=infra&team=fake
+--- response_body
+hello world
+
+
+
+=== TEST 22: verify fake team
+--- request
+GET /hello?team=fake
+--- error_code: 403
+
+
+
+=== TEST 23: set acl with external user parsed by JSONPath (parser is table)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = {\"cloud\", \"infra\"} } } };    
 end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$.orgs..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": "table",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 24: test acl with external user parsed by JSONPath (parser is table)
+--- request
+GET /hello
+--- response_body
+hello world
+
+
+
+=== TEST 25: set acl with external user parsed by JSONPath (parser is 
segmented_text)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud|infra\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$.orgs..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_separator": "\\|",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 26: test acl with external user parsed by JSONPath (parser is 
segmented_text)
+--- request
+GET /hello
+--- response_body
+hello world
+
+
+
+=== TEST 27: set acl with external user parsed by JSONPath (parser is json)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgses = { api7 = { team = \"[\\\"cloud\\\", 
\\\"infra\\\"]\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": "json",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 28: test acl with external user parsed by JSONPath (parser is json)
+--- request
+GET /hello
+--- response_body
+hello world
+
+
+
+=== TEST 29: set acl parser "segmented_text", but can not extract expect value 
by the invalid separator
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                            "functions": [
+                              "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud|infra\" } } };     end"
+                            ],
+                            "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$.orgs..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_separator": "|",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 30: test ACL with the invalid separator
+# User may want to split the text "cloud|infra" to be ["cloud", "infra"] by 
char "|", but it does not.
+# Because the char "|" is a regex expression, the text "cloud|infra" will be 
split to ['c','l','o','u','d','|','i','n','f','r','a'].
+# If you want to split text by "|" you should use "\\|".
+# This is a normal case, no error_log here.
+--- request
+GET /hello
+--- error_code: 403
+
+
+
+=== TEST 31: set external_user info that ACL can extract multiple values from 
it.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud|infra\" }, apache = { 
team = { \"devops\", \"qa\" } } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$.orgs..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_separator": "\\|",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 32: test the ACL extract multiple values from external_user info and 
the first value can not be expected.
+# User may expect the value extracted is "cloud|infra", but it is not.
+# Because the values extracted are multiple, we can not expect the value 
"cloud|infra" is the first.
+# This is a normal case, no error_log here.
+--- request
+GET /hello
+--- error_code: 403
+
+
+
+=== TEST 33: use JSONPath to extract value but a correct 
external_user_label_field and external_user_label_field_parser is missing.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud,infra\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$..team",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 34: test using JSONPath but a label key is missing.
+# Using the JSONPath "$..team" to extract value and a label key is missing, 
the ACL will use the JSONPath as the key to match labels.
+# It's obvious that our use of "$. .team" does not match any value in ACL 
allow_labels/deny_labels.
+# This is a normal case, no error_log here.
+--- request
+GET /hello
+--- error_code: 403
+
+
+
+=== TEST 35: set invalid separator
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud,infra\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                               "allow_labels": {
+                                 "org": ["api7", "apache"],
+                                 "team": ["cloud", "infra"]
+                               },
+                               "external_user_label_field": "$..team",
+                               "external_user_label_field_key": "team",
+                               "external_user_label_field_parser": 
"segmented_text",
+                               "external_user_label_field_separator": 
"(invalid(pattern",
+                               "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 36: test invalid separator, ngx.re.split will be fail.
+# The value extracted is "cloud,infra",
+# ACL parser try to parser it as Lua table.
+# It will fail and forbidden all.
+--- request
+GET /hello
+--- error_code: 403
+--- error_log eval
+qr/failed to split labels \[cloud,infra\]/
+
+
+
+=== TEST 37: set the parser "table" but the type of the value extracted is not 
a table
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud,infra\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": "table",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 38: test the parser is "table" but the type of the value extracted is 
not a table
+# The value extracted is "cloud,infra",
+# ACL parser try to parser it as Lua table.
+# It will fail and forbidden all.
+--- request
+GET /hello
+--- error_code: 403
+--- error_log
+extra_values_with_parser(): the parser is specified as table, but the type of 
value is not table: string
+
+
+
+=== TEST 39: set the parser "json" but the type of the value extracted is not 
string
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = {\"cloud\", \"infra\"} } } };    
 end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": "json",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 40: test the parser is "json" but the type of the value extracted is 
not string
+# The value extracted is {"cloud", "infra"}, a Lua table.
+# The ACL try to parser it as a serialized JSON.
+# It will fail and forbidden all.
+--- request
+GET /hello
+--- error_code: 403
+--- error_log
+extra_values_with_parser(): the parser is specified as json array, but the 
value type is not string
+
+
+
+=== TEST 41: set the parser "json" but the value extracted has no prefix "["
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"cloud\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": "json",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 42: test the parser is "json" but the value extracted has no prefix 
"["
+# The value extracted is "cloud".
+# The ACL try to parse it as a serialized JSON string.
+# It will fail and forbidden all.
+--- request
+GET /hello
+--- error_code: 403
+--- error_log
+extra_values_with_parser(): the parser is specified as json array, but the 
value do not has prefix '['
+
+
+
+=== TEST 43: set the parser "json" and the value extracted has prefix "[" but 
it is a invalid JSON
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { orgs = { api7 = { team = \"[cloud\" } } };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "$..team",
+                              "external_user_label_field_key": "team",
+                              "external_user_label_field_parser": "json",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 44: test the parser is "json" and the value extracted has prefix "[" 
but it is a invalid JSON
+# The value extracted is "cloud".
+# The ACL try to parse it as a serialized JSON string.
+# It will fail and forbidden all.
+--- request
+GET /hello
+--- error_code: 403
+--- error_log
+extra_values_with_parser(): failed to decode labels [[cloud] as array, err: 
Expected value but found invalid token at character 2
+
+
+
+=== TEST 45: set no parser, value has no prefix "[" and no separator ",", 
external_user_label_field as labels key
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "serverless-pre-function": {
+                              "functions": [
+                                "return function(conf, ctx)      
ctx.external_user = { team = \"cloud\" };     end"
+                              ],
+                              "phase": "access"
+                            },
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 46: test no parser, value has no prefix "[" and no separator ",", 
external_user_label_field as labels key
+# The value extracted is "cloud".
+# There is no parser and the value type is "string", so ACL treat it as a Lua 
table {"cloud"}.
+# It can match the ACL allow_labels, so response 200 OK.
+--- request
+GET /hello
+--- response_body
+hello world
+--- log_level: info
+--- error_log
+extra_values_without_parser(): the string value can not parsed by json or 
segmented_text
+
+
+
+=== TEST 47: TEST SCHEMA: invalid external_user_label_field_parser
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "external_user_label_field_parser": 
"an-invalid-parser",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin acl err: property 
\"external_user_label_field_parser\" validation failed: matches none of the 
enum values"}
+
+
+
+=== TEST 48: TEST SCHEMA: external_user_label_field_parser="segmented_text" 
but external_user_label_field_separator is missing
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin acl err: allOf 1 
failed: then clause did not match"}
+
+
+
+=== TEST 49: TEST SCHEMA: invalid external_user_label_field_key (specified but 
empty)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_key": "",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin acl err: property 
\"external_user_label_field_key\" validation failed: string too short, expected 
at least 1, got 0"}
+
+
+
+=== TEST 50: TEST SCHEMA: invalid external_user_label_field_key (specified but 
not string)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_separator": {},
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin acl err: property 
\"external_user_label_field_separator\" validation failed: wrong type: expected 
string, got table"}
+
+
+
+=== TEST 51: TEST SCHEMA: invalid external_user_label_field_separator 
(specified but empty)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_separator": "",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin acl err: property 
\"external_user_label_field_separator\" validation failed: string too short, 
expected at least 1, got 0"}
+
+
+
+=== TEST 52: TEST SCHEMA: invalid external_user_label_field_separator 
(specified but not string)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "org": ["api7", "apache"],
+                                "team": ["cloud", "infra"]
+                              },
+                              "external_user_label_field": "team",
+                              "external_user_label_field_parser": 
"segmented_text",
+                              "external_user_label_field_separator": {},
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin acl err: property 
\"external_user_label_field_separator\" validation failed: wrong type: expected 
string, got table"}
+
+
+
+=== TEST 53: TEST SCHEMA: invalid external_user_label_field (invalid JSONPath 
syntax)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "acl": {
+                              "allow_labels": {
+                                "team": ["cloud"]
+                              },
+                              "external_user_label_field": "$..([invalid",
+                              "rejected_code": 403
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- request
+GET /t
+--- error_code: 400
+--- response_body_like
+failed to check the configuration of plugin acl err: invalid 
external_user_label_field:.*
+
+
+
+=== TEST 54: delete route
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t( '/apisix/admin/routes/1', ngx.HTTP_DELETE )
+
+            ngx.status = code
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 55: delete jack
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t( '/apisix/admin/consumers/jack', 
ngx.HTTP_DELETE )
+
+            ngx.status = code
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 56: delete rose
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t( '/apisix/admin/consumers/rose', 
ngx.HTTP_DELETE )
+
+            ngx.status = code
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
diff --git a/t/plugin/acl2.t b/t/plugin/acl2.t
new file mode 100644
index 000000000..9d8c73773
--- /dev/null
+++ b/t/plugin/acl2.t
@@ -0,0 +1,115 @@
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: add consumer jack with comma-delimited labels
+--- 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": "jack",
+                    "plugins": {
+                        "basic-auth": {
+                            "username": "jack",
+                            "password": "123456"
+                        }
+                    },
+                    "labels": {
+                        "org": "apache",
+                        "project": "gateway,apisix,web-server"
+                    }
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: set allow_labels
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "uri": "/hello",
+                        "upstream": {
+                            "type": "roundrobin",
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            }
+                        },
+                        "plugins": {
+                            "basic-auth": {},
+                            "acl": {
+                                 "allow_labels": {
+                                    "project": ["apisix"]
+                                 }
+                            }
+                        }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 3: verify unauthorized
+--- request
+GET /hello
+--- error_code: 401
+--- response_body
+{"message":"Missing authorization in request"}
+
+
+
+=== TEST 4: verify jack
+--- request
+GET /hello
+--- more_headers
+Authorization: Basic amFjazoxMjM0NTY=
+--- response_body
+hello world

Reply via email to