Parcourir la source

Merge pull request #7817 from JimMoen/fix-auth-http

JianBo He il y a 3 ans
Parent
commit
344a754674

+ 15 - 15
apps/emqx_authn/i18n/emqx_authn_http_i18n.conf

@@ -18,7 +18,7 @@ emqx_authn_http {
       en: """HTTP request method."""
       zh: """HTTP 请求方法。"""
     }
-    label: {
+    label {
       en: """Request Method"""
       zh: """请求方法"""
     }
@@ -27,9 +27,9 @@ emqx_authn_http {
   url {
     desc {
       en: """URL of the HTTP server."""
-      zh: """HTTP 服务器地址。"""
+      zh: """认证 HTTP 服务器地址。"""
     }
-    label: {
+    label {
       en: """URL"""
       zh: """URL"""
     }
@@ -37,23 +37,23 @@ emqx_authn_http {
 
   headers {
     desc {
-      en: """HTTP request headers."""
-      zh: """HTTP request headers。"""
+      en: """List of HTTP Headers."""
+      zh: """HTTP Headers 列表"""
     }
-    label: {
-      en: """Request Headers"""
-      zh: """Request Headers"""
+    label {
+      en: """Headers"""
+      zh: """请求头"""
     }
   }
 
   headers_no_content_type {
     desc {
-      en: """HTTP request headers (without <code>content-type</code>)."""
-      zh: """HTTP request headers(无 <code>content-type</code>)。"""
+      en: """List of HTTP headers (without <code>content-type</code>)."""
+      zh: """HTTP Headers 列表 (无 <code>content-type</code>) 。"""
     }
-    label: {
-      en: """Request Headers"""
-      zh: """Request Headers"""
+    label {
+      en: """headers_no_content_type"""
+      zh: """请求头(无 content-type)"""
     }
   }
 
@@ -62,7 +62,7 @@ emqx_authn_http {
       en: """HTTP request body."""
       zh: """HTTP request body。"""
     }
-    label: {
+    label {
       en: """Request Body"""
       zh: """Request Body"""
     }
@@ -73,7 +73,7 @@ emqx_authn_http {
       en: """HTTP request timeout."""
       zh: """HTTP 请求超时时长。"""
     }
-    label: {
+    label {
       en: """Request Timeout"""
       zh: """请求超时时间"""
     }

+ 57 - 50
apps/emqx_authn/src/simple_authn/emqx_authn_http.erl

@@ -83,7 +83,10 @@ common_fields() ->
         {mechanism, emqx_authn_schema:mechanism(password_based)},
         {backend, emqx_authn_schema:backend(http)},
         {url, fun url/1},
-        {body, hoconsc:mk(map([{fuzzy, term(), binary()}]), #{desc => ?DESC(body)})},
+        {body,
+            hoconsc:mk(map([{fuzzy, term(), binary()}]), #{
+                required => false, desc => ?DESC(body)
+            })},
         {request_timeout, fun request_timeout/1}
     ] ++ emqx_authn_schema:common_fields() ++
         maps:to_list(
@@ -127,7 +130,10 @@ headers_no_content_type(desc) ->
     ?DESC(?FUNCTION_NAME);
 headers_no_content_type(converter) ->
     fun(Headers) ->
-        maps:merge(default_headers_no_content_type(), transform_header_name(Headers))
+        maps:without(
+            [<<"content-type">>],
+            maps:merge(default_headers_no_content_type(), transform_header_name(Headers))
+        )
     end;
 headers_no_content_type(default) ->
     default_headers_no_content_type();
@@ -155,26 +161,23 @@ create(_AuthenticatorID, Config) ->
 create(
     #{
         method := Method,
-        url := RawURL,
-        headers := HeadersT,
-        body := Body,
+        url := RawUrl,
+        headers := Headers,
         request_timeout := RequestTimeout
     } = Config
 ) ->
-    Headers = ensure_header_name_type(HeadersT),
-    {BsaeUrlWithPath, Query} = parse_fullpath(RawURL),
-    URIMap = parse_url(BsaeUrlWithPath),
+    {BaseUrl0, Path, Query} = parse_url(RawUrl),
+    {ok, BaseUrl} = emqx_http_lib:uri_parse(BaseUrl0),
     ResourceId = emqx_authn_utils:make_resource_id(?MODULE),
     State = #{
         method => Method,
-        path => maps:get(path, URIMap),
+        path => Path,
+        headers => Headers,
+        base_path_templete => emqx_authn_utils:parse_str(Path),
         base_query_template => emqx_authn_utils:parse_deep(
             cow_qs:parse_qs(to_bin(Query))
         ),
-        headers => maps:to_list(Headers),
-        body_template => emqx_authn_utils:parse_deep(
-            maps:to_list(Body)
-        ),
+        body_template => emqx_authn_utils:parse_deep(maps:get(body, Config, #{})),
         request_timeout => RequestTimeout,
         resource_id => ResourceId
     },
@@ -184,7 +187,7 @@ create(
             ?RESOURCE_GROUP,
             emqx_connector_http,
             Config#{
-                base_url => maps:remove(query, URIMap),
+                base_url => BaseUrl,
                 pool_type => random
             },
             #{}
@@ -273,9 +276,6 @@ destroy(#{resource_id := ResourceId}) ->
 %% Internal functions
 %%--------------------------------------------------------------------
 
-parse_fullpath(RawURL) ->
-    cow_http:parse_fullpath(to_bin(RawURL)).
-
 default_headers() ->
     maps:put(
         <<"content-type">>,
@@ -302,14 +302,14 @@ transform_header_name(Headers) ->
     ).
 
 check_ssl_opts(Conf) ->
-    {BaseUrlWithPath, _Query} = parse_fullpath(get_conf_val("url", Conf)),
-    case parse_url(BaseUrlWithPath) of
-        #{scheme := https} ->
+    {BaseUrl, _Path, _Query} = parse_url(get_conf_val("url", Conf)),
+    case BaseUrl of
+        <<"https://", _/binary>> ->
             case get_conf_val("ssl.enable", Conf) of
                 true -> ok;
                 false -> false
             end;
-        #{scheme := http} ->
+        <<"http://", _/binary>> ->
             ok
     end.
 
@@ -318,39 +318,51 @@ check_headers(Conf) ->
     Headers = get_conf_val("headers", Conf),
     Method =:= <<"post">> orelse (not maps:is_key(<<"content-type">>, Headers)).
 
-parse_url(URL) ->
-    {ok, URIMap} = emqx_http_lib:uri_parse(URL),
-    case maps:get(query, URIMap, undefined) of
-        undefined ->
-            URIMap#{query => ""};
-        _ ->
-            URIMap
+parse_url(Url) ->
+    case string:split(Url, "//", leading) of
+        [Scheme, UrlRem] ->
+            case string:split(UrlRem, "/", leading) of
+                [HostPort, Remaining] ->
+                    BaseUrl = iolist_to_binary([Scheme, "//", HostPort]),
+                    case string:split(Remaining, "?", leading) of
+                        [Path, QueryString] ->
+                            {BaseUrl, Path, QueryString};
+                        [Path] ->
+                            {BaseUrl, Path, <<>>}
+                    end;
+                [HostPort] ->
+                    {iolist_to_binary([Scheme, "//", HostPort]), <<>>, <<>>}
+            end;
+        [Url] ->
+            throw({invalid_url, Url})
     end.
 
 generate_request(Credential, #{
     method := Method,
-    path := Path,
+    headers := Headers0,
+    base_path_templete := BasePathTemplate,
     base_query_template := BaseQueryTemplate,
-    headers := Headers,
     body_template := BodyTemplate
 }) ->
+    Headers = maps:to_list(Headers0),
+    Path = emqx_authn_utils:render_str(BasePathTemplate, Credential),
+    Query = emqx_authn_utils:render_deep(BaseQueryTemplate, Credential),
     Body = emqx_authn_utils:render_deep(BodyTemplate, Credential),
-    NBaseQuery = emqx_authn_utils:render_deep(BaseQueryTemplate, Credential),
     case Method of
         get ->
-            NPath = append_query(Path, NBaseQuery ++ Body),
-            {NPath, Headers};
+            NPathQuery = append_query(to_list(Path), to_list(Query) ++ maps:to_list(Body)),
+            {NPathQuery, Headers};
         post ->
-            NPath = append_query(Path, NBaseQuery),
+            NPathQuery = append_query(to_list(Path), to_list(Query)),
             ContentType = proplists:get_value(<<"content-type">>, Headers),
             NBody = serialize_body(ContentType, Body),
-            {NPath, Headers, NBody}
+            {NPathQuery, Headers, NBody}
     end.
 
 append_query(Path, []) ->
-    Path;
+    encode_path(Path);
 append_query(Path, Query) ->
-    Path ++ "?" ++ binary_to_list(qs(Query)).
+    encode_path(Path) ++ "?" ++ binary_to_list(qs(Query)).
 
 qs(KVs) ->
     qs(KVs, []).
@@ -387,12 +399,18 @@ may_append_body(Output, {ok, _, _}) ->
     Output.
 
 uri_encode(T) ->
-    emqx_http_lib:uri_encode(to_bin(T)).
+    emqx_http_lib:uri_encode(to_list(T)).
+
+encode_path(Path) ->
+    Parts = string:split(Path, "/", all),
+    lists:flatten(["/" ++ Part || Part <- lists:map(fun uri_encode/1, Parts)]).
 
 to_list(A) when is_atom(A) ->
     atom_to_list(A);
 to_list(B) when is_binary(B) ->
-    binary_to_list(B).
+    binary_to_list(B);
+to_list(L) when is_list(L) ->
+    L.
 
 to_bin(A) when is_atom(A) ->
     atom_to_binary(A);
@@ -403,14 +421,3 @@ to_bin(L) when is_list(L) ->
 
 get_conf_val(Name, Conf) ->
     hocon_maps:get(?CONF_NS ++ "." ++ Name, Conf).
-
-ensure_header_name_type(Headers) ->
-    Fun = fun
-        (Key, _Val, Acc) when is_binary(Key) ->
-            Acc;
-        (Key, Val, Acc) when is_atom(Key) ->
-            Acc2 = maps:remove(Key, Acc),
-            BinKey = erlang:atom_to_binary(Key),
-            Acc2#{BinKey => Val}
-    end,
-    maps:fold(Fun, Headers, Headers).

+ 10 - 10
apps/emqx_authn/test/emqx_authn_api_SUITE.erl

@@ -184,14 +184,14 @@ test_authenticators(PathPrefix) ->
         InvalidConfig0
     ),
 
-    InvalidConfig1 = ValidConfig#{
+    ValidConfig1 = ValidConfig#{
         method => <<"get">>,
         headers => #{<<"content-type">> => <<"application/json">>}
     },
-    {ok, 400, _} = request(
-        post,
-        uri(PathPrefix ++ [?CONF_NS]),
-        InvalidConfig1
+    {ok, 200, _} = request(
+        put,
+        uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
+        ValidConfig1
     ),
 
     ?assertAuthenticatorsMatch(
@@ -264,21 +264,21 @@ test_authenticator(PathPrefix) ->
         InvalidConfig0
     ),
 
-    InvalidConfig1 = ValidConfig0#{
+    ValidConfig1 = ValidConfig0#{
         method => <<"get">>,
         headers => #{<<"content-type">> => <<"application/json">>}
     },
-    {ok, 400, _} = request(
+    {ok, 200, _} = request(
         put,
         uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
-        InvalidConfig1
+        ValidConfig1
     ),
 
-    ValidConfig1 = ValidConfig0#{pool_size => 9},
+    ValidConfig2 = ValidConfig0#{pool_size => 9},
     {ok, 200, _} = request(
         put,
         uri(PathPrefix ++ [?CONF_NS, "password_based:http"]),
-        ValidConfig1
+        ValidConfig2
     ),
 
     {ok, 404, _} = request(

+ 5 - 5
apps/emqx_authz/i18n/emqx_authz_api_schema_i18n.conf

@@ -60,19 +60,19 @@ emqx_authz_api_schema {
 
   headers {
     desc {
-      en: """List of HTTP headers."""
-      zh: """"""
+      en: """List of HTTP Headers."""
+      zh: """HTTP Headers 列表"""
     }
     label {
-      en: """headers"""
+      en: """Headers"""
       zh: """请求头"""
     }
   }
 
   headers_no_content_type {
     desc {
-      en: """List of HTTP headers (without `content_type`)."""
-      zh: """"""
+      en: """List of HTTP headers (without <code>content-type</code>)."""
+      zh: """HTTP Headers 列表(无 <code>content-type</code>)"""
     }
     label {
       en: """headers_no_content_type"""

+ 17 - 17
apps/emqx_authz/i18n/emqx_authz_schema_i18n.conf

@@ -122,7 +122,7 @@ and the new rules will override all rules from the old config file.
   http_get {
     desc {
       en: """Authorization using an external HTTP server (via GET requests)."""
-      zh: """使用外部 HTTP 服务器鉴权(GET 请求)."""
+      zh: """使用外部 HTTP 服务器鉴权(GET 请求)"""
     }
     label {
       en: """http_get"""
@@ -133,7 +133,7 @@ and the new rules will override all rules from the old config file.
   http_post {
     desc {
       en: """Authorization using an external HTTP server (via POST requests)."""
-      zh: """使用外部 HTTP 服务器鉴权(POST 请求)."""
+      zh: """使用外部 HTTP 服务器鉴权(POST 请求)"""
     }
     label {
       en: """http_post"""
@@ -155,29 +155,29 @@ and the new rules will override all rules from the old config file.
   url {
     desc {
       en: """URL of the auth server."""
-      zh: """认证服务器 URL"""
+      zh: """鉴权 HTTP 服务器地址。"""
     }
     label {
-      en: """url"""
-      zh: """url"""
+      en: """URL"""
+      zh: """URL"""
     }
   }
 
   headers {
     desc {
-      en: """List of HTTP headers."""
-      zh: """"""
+      en: """List of HTTP Headers."""
+      zh: """HTTP Headers 列表"""
     }
     label {
-      en: """headers"""
+      en: """Headers"""
       zh: """请求头"""
     }
   }
 
   headers_no_content_type {
     desc {
-      en: """List of HTTP headers (without `content_type`)."""
-      zh: """"""
+      en: """List of HTTP headers (without <code>content-type</code>)."""
+      zh: """HTTP Headers 列表 (无 <code>content-type</code>) 。"""
     }
     label {
       en: """headers_no_content_type"""
@@ -188,22 +188,22 @@ and the new rules will override all rules from the old config file.
   body {
     desc {
       en: """HTTP request body."""
-      zh: """HTTP 请求体"""
+      zh: """HTTP request body。"""
     }
     label {
-      en: """body"""
-      zh: """请求体"""
+      en: """Request Body"""
+      zh: """Request Body"""
     }
   }
 
   request_timeout {
     desc {
-      en: """Request timeout."""
-      zh: """请求超时时间"""
+      en: """HTTP request timeout."""
+      zh: """HTTP 请求超时时长。"""
     }
     label {
-      en: """request_timeout"""
-      zh: """请求超时"""
+      en: """Request Timeout"""
+      zh: """请求超时时间"""
     }
   }
 

+ 11 - 3
apps/emqx_authz/src/emqx_authz_api_schema.erl

@@ -102,9 +102,14 @@ authz_http_common_fields() ->
     authz_common_fields(http) ++
         [
             {url, fun url/1},
-            {body, map([{fuzzy, term(), binary()}])},
+            {body,
+                hoconsc:mk(map([{fuzzy, term(), binary()}]), #{
+                    required => false, desc => ?DESC(body)
+                })},
             {request_timeout,
-                mk_duration("Request timeout", #{default => "30s", desc => ?DESC(request_timeout)})}
+                mk_duration("Request timeout", #{
+                    required => false, default => "30s", desc => ?DESC(request_timeout)
+                })}
         ] ++
         maps:to_list(
             maps:without(
@@ -141,7 +146,10 @@ headers_no_content_type(desc) ->
     ?DESC(?FUNCTION_NAME);
 headers_no_content_type(converter) ->
     fun(Headers) ->
-        maps:merge(default_headers_no_content_type(), transform_header_name(Headers))
+        maps:without(
+            [<<"content-type">>],
+            maps:merge(default_headers_no_content_type(), transform_header_name(Headers))
+        )
     end;
 headers_no_content_type(default) ->
     default_headers_no_content_type();

+ 51 - 39
apps/emqx_authz/src/emqx_authz_http.erl

@@ -94,45 +94,49 @@ authorize(
 
 parse_config(
     #{
-        url := URL,
+        url := RawUrl,
         method := Method,
         headers := Headers,
         request_timeout := ReqTimeout
     } = Conf
 ) ->
-    {BaseURLWithPath, Query} = parse_fullpath(URL),
-    BaseURLMap = parse_url(BaseURLWithPath),
+    {BaseUrl0, Path, Query} = parse_url(RawUrl),
+    {ok, BaseUrl} = emqx_http_lib:uri_parse(BaseUrl0),
     Conf#{
         method => Method,
-        base_url => maps:remove(query, BaseURLMap),
+        base_url => BaseUrl,
+        headers => Headers,
+        base_path_templete => emqx_authz_utils:parse_str(Path, ?PLACEHOLDERS),
         base_query_template => emqx_authz_utils:parse_deep(
-            cow_qs:parse_qs(bin(Query)),
+            cow_qs:parse_qs(to_bin(Query)),
             ?PLACEHOLDERS
         ),
         body_template => emqx_authz_utils:parse_deep(
             maps:to_list(maps:get(body, Conf, #{})),
             ?PLACEHOLDERS
         ),
-        headers => Headers,
         request_timeout => ReqTimeout,
         %% pool_type default value `random`
         pool_type => random
     }.
 
-parse_fullpath(RawURL) ->
-    cow_http:parse_fullpath(bin(RawURL)).
-
-parse_url(URL) when
-    URL =:= undefined
-->
-    #{};
-parse_url(URL) ->
-    {ok, URIMap} = emqx_http_lib:uri_parse(URL),
-    case maps:get(query, URIMap, undefined) of
-        undefined ->
-            URIMap#{query => ""};
-        _ ->
-            URIMap
+parse_url(Url) ->
+    case string:split(Url, "//", leading) of
+        [Scheme, UrlRem] ->
+            case string:split(UrlRem, "/", leading) of
+                [HostPort, Remaining] ->
+                    BaseUrl = iolist_to_binary([Scheme, "//", HostPort]),
+                    case string:split(Remaining, "?", leading) of
+                        [Path, QueryString] ->
+                            {BaseUrl, Path, QueryString};
+                        [Path] ->
+                            {BaseUrl, Path, <<>>}
+                    end;
+                [HostPort] ->
+                    {iolist_to_binary([Scheme, "//", HostPort]), <<>>, <<>>}
+            end;
+        [Url] ->
+            throw({invalid_url, Url})
     end.
 
 generate_request(
@@ -141,32 +145,33 @@ generate_request(
     Client,
     #{
         method := Method,
-        base_url := #{path := Path},
-        base_query_template := BaseQueryTemplate,
         headers := Headers,
+        base_path_templete := BasePathTemplate,
+        base_query_template := BaseQueryTemplate,
         body_template := BodyTemplate
     }
 ) ->
     Values = client_vars(Client, PubSub, Topic),
+    Path = emqx_authz_utils:render_str(BasePathTemplate, Values),
+    Query = emqx_authz_utils:render_deep(BaseQueryTemplate, Values),
     Body = emqx_authz_utils:render_deep(BodyTemplate, Values),
-    NBaseQuery = emqx_authz_utils:render_deep(BaseQueryTemplate, Values),
     case Method of
         get ->
-            NPath = append_query(Path, NBaseQuery ++ Body),
+            NPath = append_query(Path, Query ++ Body),
             {NPath, Headers};
         _ ->
-            NPath = append_query(Path, NBaseQuery),
+            NPath = append_query(Path, Query),
             NBody = serialize_body(
-                proplists:get_value(<<"Accept">>, Headers, <<"application/json">>),
+                proplists:get_value(<<"accept">>, Headers, <<"application/json">>),
                 Body
             ),
             {NPath, Headers, NBody}
     end.
 
 append_query(Path, []) ->
-    Path;
+    encode_path(Path);
 append_query(Path, Query) ->
-    Path ++ "?" ++ binary_to_list(query_string(Query)).
+    encode_path(Path) ++ "?" ++ to_list(query_string(Query)).
 
 query_string(Body) ->
     query_string(Body, []).
@@ -179,13 +184,14 @@ query_string([], Acc) ->
             <<>>
     end;
 query_string([{K, V} | More], Acc) ->
-    query_string(
-        More,
-        [
-            ["&", emqx_http_lib:uri_encode(K), "=", emqx_http_lib:uri_encode(V)]
-            | Acc
-        ]
-    ).
+    query_string(More, [["&", uri_encode(K), "=", uri_encode(V)] | Acc]).
+
+uri_encode(T) ->
+    emqx_http_lib:uri_encode(to_list(T)).
+
+encode_path(Path) ->
+    Parts = string:split(Path, "/", all),
+    lists:flatten(["/" ++ Part || Part <- lists:map(fun uri_encode/1, Parts)]).
 
 serialize_body(<<"application/json">>, Body) ->
     jsx:encode(Body);
@@ -198,7 +204,13 @@ client_vars(Client, PubSub, Topic) ->
         topic => Topic
     }.
 
-bin(A) when is_atom(A) -> atom_to_binary(A, utf8);
-bin(B) when is_binary(B) -> B;
-bin(L) when is_list(L) -> list_to_binary(L);
-bin(X) -> X.
+to_list(A) when is_atom(A) ->
+    atom_to_list(A);
+to_list(B) when is_binary(B) ->
+    binary_to_list(B);
+to_list(L) when is_list(L) ->
+    L.
+
+to_bin(B) when is_binary(B) -> B;
+to_bin(L) when is_list(L) -> list_to_binary(L);
+to_bin(X) -> X.

+ 12 - 5
apps/emqx_authz/src/emqx_authz_schema.erl

@@ -29,6 +29,8 @@
 -type action() :: publish | subscribe | all.
 -type permission() :: allow | deny.
 
+-import(emqx_schema, [mk_duration/2]).
+
 -export([
     namespace/0,
     roots/0,
@@ -249,8 +251,8 @@ http_common_fields() ->
     [
         {url, fun url/1},
         {request_timeout,
-            emqx_schema:mk_duration("Request timeout", #{
-                default => "30s", desc => ?DESC(request_timeout)
+            mk_duration("Request timeout", #{
+                required => false, default => "30s", desc => ?DESC(request_timeout)
             })},
         {body, #{type => map(), required => false, desc => ?DESC(body)}}
     ] ++
@@ -303,7 +305,12 @@ headers_no_content_type(desc) ->
     ?DESC(?FUNCTION_NAME);
 headers_no_content_type(converter) ->
     fun(Headers) ->
-        maps:to_list(maps:merge(default_headers_no_content_type(), transform_header_name(Headers)))
+        maps:to_list(
+            maps:without(
+                [<<"content-type">>],
+                maps:merge(default_headers_no_content_type(), transform_header_name(Headers))
+            )
+        )
     end;
 headers_no_content_type(default) ->
     default_headers_no_content_type();
@@ -359,12 +366,12 @@ check_ssl_opts(Conf) ->
             true;
         Url ->
             case emqx_authz_http:parse_url(Url) of
-                #{scheme := https} ->
+                {<<"https", _>>, _, _} ->
                     case hocon_maps:get("config.ssl.enable", Conf) of
                         true -> true;
                         _ -> {error, ssl_not_enable}
                     end;
-                #{scheme := http} ->
+                {<<"http", _>>, _, _} ->
                     true;
                 Bad ->
                     {bad_scheme, Url, Bad}

+ 12 - 0
apps/emqx_authz/src/emqx_authz_utils.erl

@@ -25,8 +25,10 @@
     create_resource/2,
     update_config/2,
     parse_deep/2,
+    parse_str/2,
     parse_sql/3,
     render_deep/2,
+    render_str/2,
     render_sql_params/2
 ]).
 
@@ -69,6 +71,9 @@ update_config(Path, ConfigRequest) ->
 parse_deep(Template, PlaceHolders) ->
     emqx_placeholder:preproc_tmpl_deep(Template, #{placeholders => PlaceHolders}).
 
+parse_str(Template, PlaceHolders) ->
+    emqx_placeholder:preproc_tmpl(Template, #{placeholders => PlaceHolders}).
+
 parse_sql(Template, ReplaceWith, PlaceHolders) ->
     emqx_placeholder:preproc_sql(
         Template,
@@ -85,6 +90,13 @@ render_deep(Template, Values) ->
         #{return => full_binary, var_trans => fun handle_var/2}
     ).
 
+render_str(Template, Values) ->
+    emqx_placeholder:proc_tmpl(
+        Template,
+        client_vars(Values),
+        #{return => full_binary, var_trans => fun handle_var/2}
+    ).
+
 render_sql_params(ParamList, Values) ->
     emqx_placeholder:proc_tmpl(
         ParamList,

+ 48 - 0
apps/emqx_authz/test/emqx_authz_http_SUITE.erl

@@ -201,6 +201,54 @@ t_query_params(_Config) ->
         emqx_access_control:authorize(ClientInfo, publish, <<"t">>)
     ).
 
+t_path(_Config) ->
+    ok = setup_handler_and_config(
+        fun(Req0, State) ->
+            ?assertEqual(
+                <<
+                    "/authz/users/"
+                    "user%20name/"
+                    "client%20id/"
+                    "127.0.0.1/"
+                    "MQTT/"
+                    "MOUNTPOINT/"
+                    "t/1/"
+                    "publish"
+                >>,
+                cowboy_req:path(Req0)
+            ),
+            Req = cowboy_req:reply(200, Req0),
+            {ok, Req, State}
+        end,
+        #{
+            <<"url">> => <<
+                "http://127.0.0.1:33333/authz/users/"
+                "${username}/"
+                "${clientid}/"
+                "${peerhost}/"
+                "${proto_name}/"
+                "${mountpoint}/"
+                "${topic}/"
+                "${action}"
+            >>
+        }
+    ),
+
+    ClientInfo = #{
+        clientid => <<"client id">>,
+        username => <<"user name">>,
+        peerhost => {127, 0, 0, 1},
+        protocol => <<"MQTT">>,
+        mountpoint => <<"MOUNTPOINT">>,
+        zone => default,
+        listener => {tcp, default}
+    },
+
+    ?assertEqual(
+        allow,
+        emqx_access_control:authorize(ClientInfo, publish, <<"t/1">>)
+    ).
+
 t_json_body(_Config) ->
     ok = setup_handler_and_config(
         fun(Req0, State) ->