Просмотр исходного кода

Merge pull request #12750 from zmstone/0316-introduce-client-attrs

0316 introduce client_attrs
Zaiming (Stone) Shi 1 год назад
Родитель
Сommit
beb9152d50
35 измененных файлов с 621 добавлено и 96 удалено
  1. 5 2
      apps/emqx/include/emqx_placeholder.hrl
  2. 137 9
      apps/emqx/src/emqx_channel.erl
  3. 1 1
      apps/emqx/src/emqx_cm.erl
  4. 33 13
      apps/emqx/src/emqx_mountpoint.erl
  5. 32 1
      apps/emqx/src/emqx_schema.erl
  6. 8 3
      apps/emqx/src/emqx_types.erl
  7. 52 0
      apps/emqx/test/emqx_client_SUITE.erl
  8. 2 1
      apps/emqx/test/emqx_config_SUITE.erl
  9. 30 0
      apps/emqx/test/emqx_listeners_SUITE.erl
  10. 28 0
      apps/emqx/test/emqx_mountpoint_SUITE.erl
  11. 26 1
      apps/emqx_auth/src/emqx_authn/emqx_authn_utils.erl
  12. 2 1
      apps/emqx_auth/src/emqx_authz/emqx_authz_rule.erl
  13. 1 15
      apps/emqx_auth/src/emqx_authz/emqx_authz_utils.erl
  14. 31 0
      apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl
  15. 24 0
      apps/emqx_auth/test/emqx_authz/emqx_authz_file_SUITE.erl
  16. 4 3
      apps/emqx_auth_http/src/emqx_authn_http.erl
  17. 2 1
      apps/emqx_auth_http/src/emqx_authz_http.erl
  18. 20 10
      apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl
  19. 1 1
      apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src
  20. 17 15
      apps/emqx_auth_jwt/src/emqx_authn_jwt.erl
  21. 1 1
      apps/emqx_auth_mongodb/src/emqx_auth_mongodb.app.src
  22. 2 1
      apps/emqx_auth_mongodb/src/emqx_authz_mongodb.erl
  23. 1 1
      apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src
  24. 2 1
      apps/emqx_auth_mysql/src/emqx_authz_mysql.erl
  25. 1 1
      apps/emqx_auth_postgresql/src/emqx_auth_postgresql.app.src
  26. 2 1
      apps/emqx_auth_postgresql/src/emqx_authz_postgresql.erl
  27. 1 1
      apps/emqx_auth_redis/src/emqx_auth_redis.app.src
  28. 2 1
      apps/emqx_auth_redis/src/emqx_authz_redis.erl
  29. 2 2
      apps/emqx_conf/src/emqx_conf.erl
  30. 42 2
      apps/emqx_utils/src/emqx_template.erl
  31. 9 1
      apps/emqx_utils/src/emqx_utils.erl
  32. 12 0
      apps/emqx_utils/test/emqx_template_SUITE.erl
  33. 37 0
      changes/ce/feat-12750.en.md
  34. 1 1
      rel/i18n/emqx_mgmt_api_trace.hocon
  35. 50 5
      rel/i18n/emqx_schema.hocon

+ 5 - 2
apps/emqx/include/emqx_placeholder.hrl

@@ -31,11 +31,14 @@
 -define(PH_CERT_SUBJECT, ?PH(?VAR_CERT_SUBJECT)).
 -define(PH_CERT_CN_NAME, ?PH(?VAR_CERT_CN_NAME)).
 
-%% MQTT
+%% MQTT/Gateway
 -define(VAR_PASSWORD, "password").
 -define(VAR_CLIENTID, "clientid").
 -define(VAR_USERNAME, "username").
 -define(VAR_TOPIC, "topic").
+-define(VAR_ENDPOINT_NAME, "endpoint_name").
+-define(VAR_NS_CLIENT_ATTRS, {var_namespace, "client_attrs"}).
+
 -define(PH_PASSWORD, ?PH(?VAR_PASSWORD)).
 -define(PH_CLIENTID, ?PH(?VAR_CLIENTID)).
 -define(PH_FROM_CLIENTID, ?PH("from_clientid")).
@@ -89,7 +92,7 @@
 -define(PH_NODE, ?PH("node")).
 -define(PH_REASON, ?PH("reason")).
 
--define(PH_ENDPOINT_NAME, ?PH("endpoint_name")).
+-define(PH_ENDPOINT_NAME, ?PH(?VAR_ENDPOINT_NAME)).
 -define(VAR_RETAIN, "retain").
 -define(PH_RETAIN, ?PH(?VAR_RETAIN)).
 

+ 137 - 9
apps/emqx/src/emqx_channel.erl

@@ -251,7 +251,7 @@ init(
             MP -> MP
         end,
     ListenerId = emqx_listeners:listener_id(Type, Listener),
-    ClientInfo = set_peercert_infos(
+    ClientInfo0 = set_peercert_infos(
         Peercert,
         #{
             zone => Zone,
@@ -269,6 +269,8 @@ init(
         },
         Zone
     ),
+    AttrExtractionConfig = get_mqtt_conf(Zone, client_attrs_init),
+    ClientInfo = initialize_client_attrs_from_cert(AttrExtractionConfig, ClientInfo0, Peercert),
     {NClientInfo, NConnInfo} = take_ws_cookie(ClientInfo, ConnInfo),
     #channel{
         conninfo = NConnInfo,
@@ -1570,7 +1572,8 @@ enrich_client(ConnPkt, Channel = #channel{clientinfo = ClientInfo}) ->
             fun set_bridge_mode/2,
             fun maybe_username_as_clientid/2,
             fun maybe_assign_clientid/2,
-            fun fix_mountpoint/2
+            %% attr init should happen after clientid and username assign
+            fun maybe_set_client_initial_attr/2
         ],
         ConnPkt,
         ClientInfo
@@ -1582,6 +1585,47 @@ enrich_client(ConnPkt, Channel = #channel{clientinfo = ClientInfo}) ->
             {error, ReasonCode, Channel#channel{clientinfo = NClientInfo}}
     end.
 
+initialize_client_attrs_from_cert(
+    #{
+        extract_from := From,
+        extract_regexp := Regexp,
+        extract_as := AttrName
+    },
+    ClientInfo,
+    Peercert
+) when From =:= cn orelse From =:= dn ->
+    case extract_client_attr_from_cert(From, Regexp, Peercert) of
+        {ok, Value} ->
+            ?SLOG(
+                debug,
+                #{
+                    msg => "client_attr_init_from_cert",
+                    extracted_as => AttrName,
+                    extracted_value => Value
+                }
+            ),
+            ClientInfo#{client_attrs => #{AttrName => Value}};
+        _ ->
+            ClientInfo#{client_attrs => #{}}
+    end;
+initialize_client_attrs_from_cert(_, ClientInfo, _Peercert) ->
+    ClientInfo.
+
+extract_client_attr_from_cert(cn, Regexp, Peercert) ->
+    CN = esockd_peercert:common_name(Peercert),
+    re_extract(CN, Regexp);
+extract_client_attr_from_cert(dn, Regexp, Peercert) ->
+    DN = esockd_peercert:subject(Peercert),
+    re_extract(DN, Regexp).
+
+re_extract(Str, Regexp) when is_binary(Str) ->
+    case re:run(Str, Regexp, [{capture, all_but_first, list}]) of
+        {match, [_ | _] = List} -> {ok, iolist_to_binary(List)};
+        _ -> nomatch
+    end;
+re_extract(_NotStr, _Regexp) ->
+    ignored.
+
 set_username(
     #mqtt_packet_connect{username = Username},
     ClientInfo = #{username := undefined}
@@ -1622,11 +1666,81 @@ maybe_assign_clientid(#mqtt_packet_connect{clientid = <<>>}, ClientInfo) ->
 maybe_assign_clientid(#mqtt_packet_connect{clientid = ClientId}, ClientInfo) ->
     {ok, ClientInfo#{clientid => ClientId}}.
 
-fix_mountpoint(_ConnPkt, #{mountpoint := undefined}) ->
-    ok;
-fix_mountpoint(_ConnPkt, ClientInfo = #{mountpoint := MountPoint}) ->
+maybe_set_client_initial_attr(ConnPkt, #{zone := Zone} = ClientInfo0) ->
+    Config = get_mqtt_conf(Zone, client_attrs_init),
+    ClientInfo = initialize_client_attrs_from_user_property(Config, ConnPkt, ClientInfo0),
+    Attrs = maps:get(client_attrs, ClientInfo, #{}),
+    case extract_attr_from_clientinfo(Config, ClientInfo) of
+        {ok, Value} ->
+            #{extract_as := Name} = Config,
+            ?SLOG(
+                debug,
+                #{
+                    msg => "client_attr_init_from_clientinfo",
+                    extracted_as => Name,
+                    extracted_value => Value
+                }
+            ),
+            {ok, ClientInfo#{client_attrs => Attrs#{Name => Value}}};
+        _ ->
+            {ok, ClientInfo}
+    end.
+
+initialize_client_attrs_from_user_property(
+    #{
+        extract_from := user_property,
+        extract_as := PropertyKey
+    },
+    ConnPkt,
+    ClientInfo
+) ->
+    case extract_client_attr_from_user_property(ConnPkt, PropertyKey) of
+        {ok, Value} ->
+            ?SLOG(
+                debug,
+                #{
+                    msg => "client_attr_init_from_user_property",
+                    extracted_as => PropertyKey,
+                    extracted_value => Value
+                }
+            ),
+            ClientInfo#{client_attrs => #{PropertyKey => Value}};
+        _ ->
+            ClientInfo
+    end;
+initialize_client_attrs_from_user_property(_, _ConnInfo, ClientInfo) ->
+    ClientInfo.
+
+extract_client_attr_from_user_property(
+    #mqtt_packet_connect{properties = #{'User-Property' := UserProperty}}, PropertyKey
+) ->
+    case lists:keyfind(PropertyKey, 1, UserProperty) of
+        {_, Value} ->
+            {ok, Value};
+        _ ->
+            not_found
+    end;
+extract_client_attr_from_user_property(_ConnPkt, _PropertyKey) ->
+    ignored.
+
+extract_attr_from_clientinfo(#{extract_from := clientid, extract_regexp := Regexp}, #{
+    clientid := ClientId
+}) ->
+    re_extract(ClientId, Regexp);
+extract_attr_from_clientinfo(#{extract_from := username, extract_regexp := Regexp}, #{
+    username := Username
+}) when
+    Username =/= undefined
+->
+    re_extract(Username, Regexp);
+extract_attr_from_clientinfo(_Config, _CLientInfo) ->
+    ignored.
+
+fix_mountpoint(#{mountpoint := undefined} = ClientInfo) ->
+    ClientInfo;
+fix_mountpoint(ClientInfo = #{mountpoint := MountPoint}) ->
     MountPoint1 = emqx_mountpoint:replvar(MountPoint, ClientInfo),
-    {ok, ClientInfo#{mountpoint := MountPoint1}}.
+    ClientInfo#{mountpoint := MountPoint1}.
 
 %%--------------------------------------------------------------------
 %% Set log metadata
@@ -1735,9 +1849,23 @@ do_authenticate(Credential, #channel{clientinfo = ClientInfo} = Channel) ->
             {error, emqx_reason_codes:connack_error(Reason)}
     end.
 
-merge_auth_result(ClientInfo, AuthResult) when is_map(ClientInfo) andalso is_map(AuthResult) ->
-    IsSuperuser = maps:get(is_superuser, AuthResult, false),
-    maps:merge(ClientInfo, AuthResult#{is_superuser => IsSuperuser}).
+%% Merge authentication result into ClientInfo
+%% Authentication result may include:
+%% 1. `is_superuser': The superuser flag from various backends
+%% 2. `acl': ACL rules from JWT, HTTP auth backend
+%% 3. `client_attrs': Extra client attributes from JWT, HTTP auth backend
+%% 4. Maybe more non-standard fields used by hook callbacks
+merge_auth_result(ClientInfo, AuthResult0) when is_map(ClientInfo) andalso is_map(AuthResult0) ->
+    IsSuperuser = maps:get(is_superuser, AuthResult0, false),
+    AuthResult = maps:without([client_attrs], AuthResult0),
+    Attrs0 = maps:get(client_attrs, ClientInfo, #{}),
+    Attrs1 = maps:get(client_attrs, AuthResult0, #{}),
+    Attrs = maps:merge(Attrs0, Attrs1),
+    NewClientInfo = maps:merge(
+        ClientInfo#{client_attrs => Attrs},
+        AuthResult#{is_superuser => IsSuperuser}
+    ),
+    fix_mountpoint(NewClientInfo).
 
 %%--------------------------------------------------------------------
 %% Process Topic Alias

+ 1 - 1
apps/emqx/src/emqx_cm.erl

@@ -227,7 +227,7 @@ get_chan_info(ClientId, ChanPid) ->
     wrap_rpc(emqx_cm_proto_v2:get_chan_info(ClientId, ChanPid)).
 
 %% @doc Update infos of the channel.
--spec set_chan_info(emqx_types:clientid(), emqx_types:attrs()) -> boolean().
+-spec set_chan_info(emqx_types:clientid(), emqx_types:channel_attrs()) -> boolean().
 set_chan_info(ClientId, Info) when ?IS_CLIENTID(ClientId) ->
     Chan = {ClientId, self()},
     try

+ 33 - 13
apps/emqx/src/emqx_mountpoint.erl

@@ -28,10 +28,19 @@
 
 -export([replvar/2]).
 
+-export([lookup/2]).
+
 -export_type([mountpoint/0]).
 
 -type mountpoint() :: binary().
 
+-define(ALLOWED_VARS, [
+    ?VAR_CLIENTID,
+    ?VAR_USERNAME,
+    ?VAR_ENDPOINT_NAME,
+    ?VAR_NS_CLIENT_ATTRS
+]).
+
 -spec mount(option(mountpoint()), Any) -> Any when
     Any ::
         emqx_types:topic()
@@ -88,17 +97,28 @@ unmount_maybe_share(MountPoint, TopicFilter = #share{topic = Topic}) when
 replvar(undefined, _Vars) ->
     undefined;
 replvar(MountPoint, Vars) ->
-    ClientID = maps:get(clientid, Vars, undefined),
-    UserName = maps:get(username, Vars, undefined),
-    EndpointName = maps:get(endpoint_name, Vars, undefined),
-    List = [
-        {?PH_CLIENTID, ClientID},
-        {?PH_USERNAME, UserName},
-        {?PH_ENDPOINT_NAME, EndpointName}
-    ],
-    lists:foldl(fun feed_var/2, MountPoint, List).
+    Template = parse(MountPoint),
+    {String, _Errors} = emqx_template:render(Template, {?MODULE, Vars}),
+    unicode:characters_to_binary(String).
+
+lookup([<<?VAR_CLIENTID>>], #{clientid := ClientId}) when is_binary(ClientId) ->
+    {ok, ClientId};
+lookup([<<?VAR_USERNAME>>], #{username := Username}) when is_binary(Username) ->
+    {ok, Username};
+lookup([<<?VAR_ENDPOINT_NAME>>], #{endpoint_name := Name}) when is_binary(Name) ->
+    {ok, Name};
+lookup([<<"client_attrs">>, AttrName], #{client_attrs := Attrs}) when is_map(Attrs) ->
+    Original = iolist_to_binary(["${client_attrs.", AttrName, "}"]),
+    {ok, maps:get(AttrName, Attrs, Original)};
+lookup(Accessor, _) ->
+    {ok, iolist_to_binary(["${", lists:join(".", Accessor), "}"])}.
 
-feed_var({_PlaceHolder, undefined}, MountPoint) ->
-    MountPoint;
-feed_var({PlaceHolder, Value}, MountPoint) ->
-    emqx_topic:feed_var(PlaceHolder, Value, MountPoint).
+parse(Template) ->
+    Parsed = emqx_template:parse(Template),
+    case emqx_template:validate(?ALLOWED_VARS, Parsed) of
+        ok ->
+            Parsed;
+        {error, _Disallowed} ->
+            Escaped = emqx_template:escape_disallowed(Parsed, ?ALLOWED_VARS),
+            emqx_template:parse(Escaped)
+    end.

+ 32 - 1
apps/emqx/src/emqx_schema.erl

@@ -1731,7 +1731,28 @@ fields("session_persistence") ->
             )}
     ];
 fields(durable_storage) ->
-    emqx_ds_schema:schema().
+    emqx_ds_schema:schema();
+fields("client_attrs_init") ->
+    [
+        {extract_from,
+            sc(
+                hoconsc:enum([clientid, username, cn, dn, user_property]),
+                #{desc => ?DESC("client_attrs_init_extract_from")}
+            )},
+        {extract_regexp, sc(binary(), #{desc => ?DESC("client_attrs_init_extract_regexp")})},
+        {extract_as,
+            sc(binary(), #{
+                default => <<"alias">>,
+                desc => ?DESC("client_attrs_init_extract_as"),
+                validator => fun restricted_string/1
+            })}
+    ].
+
+restricted_string(Str) ->
+    case emqx_utils:is_restricted_str(Str) of
+        true -> ok;
+        false -> {error, <<"Invalid string for attribute name">>}
+    end.
 
 mqtt_listener(Bind) ->
     base_listener(Bind) ++
@@ -1987,6 +2008,8 @@ desc("session_persistence") ->
     "Settings governing durable sessions persistence.";
 desc(durable_storage) ->
     ?DESC(durable_storage);
+desc("client_attrs_init") ->
+    ?DESC(client_attrs_init);
 desc(_) ->
     undefined.
 
@@ -3526,6 +3549,14 @@ mqtt_general() ->
                     default => disabled,
                     desc => ?DESC(mqtt_peer_cert_as_clientid)
                 }
+            )},
+        {"client_attrs_init",
+            sc(
+                hoconsc:union([disabled, ref("client_attrs_init")]),
+                #{
+                    default => disabled,
+                    desc => ?DESC("client_attrs_init")
+                }
             )}
     ].
 %% All session's importance should be lower than general part to organize document.

+ 8 - 3
apps/emqx/src/emqx_types.erl

@@ -54,7 +54,8 @@
     password/0,
     peerhost/0,
     peername/0,
-    protocol/0
+    protocol/0,
+    client_attrs/0
 ]).
 
 -export_type([
@@ -106,7 +107,7 @@
 
 -export_type([
     caps/0,
-    attrs/0,
+    channel_attrs/0,
     infos/0,
     stats/0
 ]).
@@ -189,8 +190,12 @@
     anonymous => boolean(),
     cn => binary(),
     dn => binary(),
+    %% Extra client attributes, commented out for bpapi spec backward compatibility.
+    %% This field is never used in RPC calls.
+    %% client_attrs => client_attrs(),
     atom() => term()
 }.
+-type client_attrs() :: #{binary() => binary()}.
 -type clientid() :: binary() | atom().
 -type username() :: option(binary()).
 -type password() :: option(binary()).
@@ -270,7 +275,7 @@
 -type command() :: #command{}.
 
 -type caps() :: emqx_mqtt_caps:caps().
--type attrs() :: #{atom() => term()}.
+-type channel_attrs() :: #{atom() => term()}.
 -type infos() :: #{atom() => term()}.
 -type stats() :: [{atom(), term()}].
 

+ 52 - 0
apps/emqx/test/emqx_client_SUITE.erl

@@ -75,6 +75,9 @@ groups() ->
         {mqttv5, [non_parallel_tests], [t_basic_with_props_v5, t_v5_receive_maximim_in_connack]},
         {others, [non_parallel_tests], [
             t_username_as_clientid,
+            t_certcn_as_alias,
+            t_certdn_as_alias,
+            t_client_attr_from_user_property,
             t_certcn_as_clientid_default_config_tls,
             t_certcn_as_clientid_tlsv1_3,
             t_certcn_as_clientid_tlsv1_2,
@@ -384,6 +387,55 @@ t_username_as_clientid(_) ->
     end,
     emqtt:disconnect(C).
 
+t_certcn_as_alias(_) ->
+    test_cert_extraction_as_alias(cn).
+
+t_certdn_as_alias(_) ->
+    test_cert_extraction_as_alias(dn).
+
+test_cert_extraction_as_alias(Which) ->
+    %% extract the first two chars
+    Re = <<"^(..).*$">>,
+    ClientId = iolist_to_binary(["ClientIdFor_", atom_to_list(Which)]),
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], #{
+        extract_from => Which,
+        extract_regexp => Re,
+        extract_as => <<"alias">>
+    }),
+    SslConf = emqx_common_test_helpers:client_mtls('tlsv1.2'),
+    {ok, Client} = emqtt:start_link([
+        {clientid, ClientId}, {port, 8883}, {ssl, true}, {ssl_opts, SslConf}
+    ]),
+    {ok, _} = emqtt:connect(Client),
+    %% assert only two chars are extracted
+    ?assertMatch(
+        #{clientinfo := #{client_attrs := #{<<"alias">> := <<_, _>>}}},
+        emqx_cm:get_chan_info(ClientId)
+    ),
+    emqtt:disconnect(Client).
+
+t_client_attr_from_user_property(_Config) ->
+    ClientId = atom_to_binary(?FUNCTION_NAME),
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], #{
+        extract_from => user_property,
+        extract_as => <<"group">>
+    }),
+    SslConf = emqx_common_test_helpers:client_mtls('tlsv1.3'),
+    {ok, Client} = emqtt:start_link([
+        {clientid, ClientId},
+        {port, 8883},
+        {ssl, true},
+        {ssl_opts, SslConf},
+        {proto_ver, v5},
+        {properties, #{'User-Property' => [{<<"group">>, <<"g1">>}]}}
+    ]),
+    {ok, _} = emqtt:connect(Client),
+    %% assert only two chars are extracted
+    ?assertMatch(
+        #{clientinfo := #{client_attrs := #{<<"group">> := <<"g1">>}}},
+        emqx_cm:get_chan_info(ClientId)
+    ),
+    emqtt:disconnect(Client).
 t_certcn_as_clientid_default_config_tls(_) ->
     tls_certcn_as_clientid(default).
 

+ 2 - 1
apps/emqx/test/emqx_config_SUITE.erl

@@ -453,7 +453,8 @@ zone_global_defaults() ->
                 strict_mode => false,
                 upgrade_qos => false,
                 use_username_as_clientid => false,
-                wildcard_subscription => true
+                wildcard_subscription => true,
+                client_attrs_init => disabled
             },
         overload_protection =>
             #{

+ 30 - 0
apps/emqx/test/emqx_listeners_SUITE.erl

@@ -143,6 +143,36 @@ t_max_conns_tcp(_Config) ->
         )
     end).
 
+t_client_attr_as_mountpoint(_Config) ->
+    Port = emqx_common_test_helpers:select_free_port(tcp),
+    ListenerConf = #{
+        <<"bind">> => format_bind({"127.0.0.1", Port}),
+        <<"limiter">> => #{},
+        <<"mountpoint">> => <<"groups/${client_attrs.ns}/">>
+    },
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], #{
+        extract_from => clientid,
+        extract_regexp => <<"^(.+)-.+$">>,
+        extract_as => <<"ns">>
+    }),
+    emqx_logger:set_log_level(debug),
+    with_listener(tcp, attr_as_moutpoint, ListenerConf, fun() ->
+        {ok, Client} = emqtt:start_link(#{
+            hosts => [{"127.0.0.1", Port}],
+            clientid => <<"abc-123">>
+        }),
+        unlink(Client),
+        {ok, _} = emqtt:connect(Client),
+        TopicPrefix = atom_to_binary(?FUNCTION_NAME),
+        SubTopic = <<TopicPrefix/binary, "/#">>,
+        MatchTopic = <<"groups/abc/", TopicPrefix/binary, "/1">>,
+        {ok, _, [1]} = emqtt:subscribe(Client, SubTopic, 1),
+        ?assertMatch([_], emqx_router:match_routes(MatchTopic)),
+        emqtt:stop(Client)
+    end),
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], disabled),
+    ok.
+
 t_current_conns_tcp(_Config) ->
     Port = emqx_common_test_helpers:select_free_port(tcp),
     Conf = #{

+ 28 - 0
apps/emqx/test/emqx_mountpoint_SUITE.erl

@@ -116,4 +116,32 @@ t_replvar(_) ->
                 username => undefined
             }
         )
+    ),
+    ?assertEqual(
+        <<"mount/g1/clientid/">>,
+        replvar(
+            <<"mount/${client_attrs.group}/${clientid}/">>,
+            #{
+                clientid => <<"clientid">>,
+                client_attrs => #{<<"group">> => <<"g1">>}
+            }
+        )
+    ),
+    ?assertEqual(
+        <<"mount/${client_attrs.group}/clientid/">>,
+        replvar(
+            <<"mount/${client_attrs.group}/${clientid}/">>,
+            #{
+                clientid => <<"clientid">>
+            }
+        )
+    ),
+    ?assertEqual(
+        <<"mount/${not.allowed}/clientid/">>,
+        replvar(
+            <<"mount/${not.allowed}/${clientid}/">>,
+            #{
+                clientid => <<"clientid">>
+            }
+        )
     ).

+ 26 - 1
apps/emqx_auth/src/emqx_authn/emqx_authn_utils.erl

@@ -32,6 +32,7 @@
     render_urlencoded_str/2,
     render_sql_params/2,
     is_superuser/1,
+    client_attrs/1,
     bin/1,
     ensure_apps_started/1,
     cleanup_resources/0,
@@ -45,13 +46,16 @@
     default_headers_no_content_type/0
 ]).
 
+%% VAR_NS_CLIENT_ATTRS is added here because it can be initialized before authn.
+%% NOTE: authn return may add more to (or even overwrite) client_attrs.
 -define(ALLOWED_VARS, [
     ?VAR_USERNAME,
     ?VAR_CLIENTID,
     ?VAR_PASSWORD,
     ?VAR_PEERHOST,
     ?VAR_CERT_SUBJECT,
-    ?VAR_CERT_CN_NAME
+    ?VAR_CERT_CN_NAME,
+    ?VAR_NS_CLIENT_ATTRS
 ]).
 
 -define(DEFAULT_RESOURCE_OPTS, #{
@@ -204,6 +208,27 @@ is_superuser(#{<<"is_superuser">> := Value}) ->
 is_superuser(#{}) ->
     #{is_superuser => false}.
 
+client_attrs(#{<<"client_attrs">> := Attrs}) ->
+    #{client_attrs => drop_invalid_attr(Attrs)};
+client_attrs(_) ->
+    #{client_attrs => #{}}.
+
+drop_invalid_attr(Map) when is_map(Map) ->
+    maps:from_list(do_drop_invalid_attr(maps:to_list(Map))).
+
+do_drop_invalid_attr([]) ->
+    [];
+do_drop_invalid_attr([{K, V} | More]) ->
+    case emqx_utils:is_restricted_str(K) of
+        true ->
+            [{iolist_to_binary(K), iolist_to_binary(V)} | do_drop_invalid_attr(More)];
+        false ->
+            ?SLOG(debug, #{msg => "invalid_client_attr_dropped", attr_name => K}, #{
+                tag => "AUTHN"
+            }),
+            do_drop_invalid_attr(More)
+    end.
+
 ensure_apps_started(bcrypt) ->
     {ok, _} = application:ensure_all_started(bcrypt),
     ok;

+ 2 - 1
apps/emqx_auth/src/emqx_authz/emqx_authz_rule.erl

@@ -88,6 +88,7 @@
 -type rule_precompile() :: {permission(), who_condition(), action_precompile(), [topic_filter()]}.
 
 -define(IS_PERMISSION(Permission), (Permission =:= allow orelse Permission =:= deny)).
+-define(ALLOWED_VARS, [?VAR_USERNAME, ?VAR_CLIENTID, ?VAR_NS_CLIENT_ATTRS]).
 
 -spec compile(permission(), who_condition(), action_precompile(), [topic_filter()]) -> rule().
 compile(Permission, Who, Action, TopicFilters) ->
@@ -223,7 +224,7 @@ compile_topic(<<"eq ", Topic/binary>>) ->
 compile_topic({eq, Topic}) ->
     {eq, emqx_topic:words(bin(Topic))};
 compile_topic(Topic) ->
-    Template = emqx_authz_utils:parse_str(Topic, [?VAR_USERNAME, ?VAR_CLIENTID]),
+    Template = emqx_authz_utils:parse_str(Topic, ?ALLOWED_VARS),
     case emqx_template:is_const(Template) of
         true -> emqx_topic:words(bin(Topic));
         false -> {pattern, Template}

+ 1 - 15
apps/emqx_auth/src/emqx_authz/emqx_authz_utils.erl

@@ -139,7 +139,7 @@ handle_disallowed_placeholders(Template, Source, Allowed) ->
                     " However, consider using `${$}` escaping for literal `$` where"
                     " needed to avoid unexpected results."
             }),
-            Result = prerender_disallowed_placeholders(Template, Allowed),
+            Result = emqx_template:escape_disallowed(Template, Allowed),
             case Source of
                 {string, _} ->
                     emqx_template:parse(Result);
@@ -148,20 +148,6 @@ handle_disallowed_placeholders(Template, Source, Allowed) ->
             end
     end.
 
-prerender_disallowed_placeholders(Template, Allowed) ->
-    {Result, _} = emqx_template:render(Template, #{}, #{
-        var_trans => fun(Name, _) ->
-            % NOTE
-            % Rendering disallowed placeholders in escaped form, which will then
-            % parse as a literal string.
-            case lists:member(Name, Allowed) of
-                true -> "${" ++ Name ++ "}";
-                false -> "${$}{" ++ Name ++ "}"
-            end
-        end
-    }),
-    Result.
-
 render_deep(Template, Values) ->
     % NOTE
     % Ignoring errors here, undefined bindings will be replaced with empty string.

+ 31 - 0
apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl

@@ -19,6 +19,7 @@
 -compile(export_all).
 
 -include("emqx_authz.hrl").
+-include_lib("emqx/include/emqx_mqtt.hrl").
 -include_lib("emqx/include/emqx.hrl").
 -include_lib("eunit/include/eunit.hrl").
 -include_lib("common_test/include/ct.hrl").
@@ -168,6 +169,16 @@ end_per_testcase(_TestCase, _Config) ->
     )
 ).
 
+%% Allow all clients to publish or subscribe to topics with their alias as prefix.
+-define(SOURCE_FILE_CLIENT_ATTR,
+    ?SOURCE_FILE(
+        <<
+            "{allow,all,all,[\"${client_attrs.alias}/#\"]}.\n"
+            "{deny, all}."
+        >>
+    )
+).
+
 %%------------------------------------------------------------------------------
 %% Testcases
 %%------------------------------------------------------------------------------
@@ -544,6 +555,26 @@ t_publish_last_will_testament_denied_topic(_Config) ->
 
     ok.
 
+t_alias_prefix(_Config) ->
+    {ok, _} = emqx_authz:update(?CMD_REPLACE, [?SOURCE_FILE_CLIENT_ATTR]),
+    ExtractSuffix = <<"^.*-(.*)$">>,
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], #{
+        extract_from => clientid,
+        extract_regexp => ExtractSuffix,
+        extract_as => <<"alias">>
+    }),
+    ClientId = <<"org1-name2">>,
+    SubTopic = <<"name2/#">>,
+    SubTopicNotAllowed = <<"name3/#">>,
+    {ok, C} = emqtt:start_link([{clientid, ClientId}, {proto_ver, v5}]),
+    ?assertMatch({ok, _}, emqtt:connect(C)),
+    ?assertMatch({ok, _, [?RC_SUCCESS]}, emqtt:subscribe(C, SubTopic)),
+    ?assertMatch({ok, _, [?RC_NOT_AUTHORIZED]}, emqtt:subscribe(C, SubTopicNotAllowed)),
+    unlink(C),
+    emqtt:stop(C),
+    emqx_config:put_zone_conf(default, [mqtt, client_attrs_init], disalbed),
+    ok.
+
 %% client is allowed by ACL to publish to its LWT topic, is connected,
 %% and then gets banned and kicked out while connected.  Should not
 %% publish LWT.

+ 24 - 0
apps/emqx_auth/test/emqx_authz/emqx_authz_file_SUITE.erl

@@ -74,6 +74,30 @@ t_ok(_Config) ->
         emqx_access_control:authorize(ClientInfo, ?AUTHZ_SUBSCRIBE, <<"t">>)
     ).
 
+t_client_attrs(_Config) ->
+    ClientInfo0 = emqx_authz_test_lib:base_client_info(),
+    ClientInfo = ClientInfo0#{client_attrs => #{<<"device_id">> => <<"id1">>}},
+
+    ok = setup_config(?RAW_SOURCE#{
+        <<"rules">> => <<"{allow, all, all, [\"t/${client_attrs.device_id}/#\"]}.">>
+    }),
+
+    ?assertEqual(
+        allow,
+        emqx_access_control:authorize(ClientInfo, ?AUTHZ_PUBLISH, <<"t/id1/1">>)
+    ),
+
+    ?assertEqual(
+        allow,
+        emqx_access_control:authorize(ClientInfo, ?AUTHZ_SUBSCRIBE, <<"t/id1/#">>)
+    ),
+
+    ?assertEqual(
+        deny,
+        emqx_access_control:authorize(ClientInfo, ?AUTHZ_SUBSCRIBE, <<"t/id2/#">>)
+    ),
+    ok.
+
 t_rich_actions(_Config) ->
     ClientInfo = emqx_authz_test_lib:base_client_info(),
 

+ 4 - 3
apps/emqx_auth_http/src/emqx_authn_http.erl

@@ -197,9 +197,10 @@ handle_response(Headers, Body) ->
         {ok, NBody} ->
             case maps:get(<<"result">>, NBody, <<"ignore">>) of
                 <<"allow">> ->
-                    Res = emqx_authn_utils:is_superuser(NBody),
-                    %% TODO: Return by user property
-                    {ok, Res#{user_property => maps:get(<<"user_property">>, NBody, #{})}};
+                    IsSuperuser = emqx_authn_utils:is_superuser(NBody),
+                    Attrs = emqx_authn_utils:client_attrs(NBody),
+                    Result = maps:merge(IsSuperuser, Attrs),
+                    {ok, Result};
                 <<"deny">> ->
                     {error, not_authorized};
                 <<"ignore">> ->

+ 2 - 1
apps/emqx_auth_http/src/emqx_authz_http.erl

@@ -47,7 +47,8 @@
     ?VAR_TOPIC,
     ?VAR_ACTION,
     ?VAR_CERT_SUBJECT,
-    ?VAR_CERT_CN_NAME
+    ?VAR_CERT_CN_NAME,
+    ?VAR_NS_CLIENT_ATTRS
 ]).
 
 -define(ALLOWED_VARS_RICH_ACTIONS, [

+ 20 - 10
apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl

@@ -36,7 +36,8 @@
     listener => 'tcp:default',
     protocol => mqtt,
     cert_subject => <<"cert_subject_data">>,
-    cert_common_name => <<"cert_common_name_data">>
+    cert_common_name => <<"cert_common_name_data">>,
+    client_attrs => #{<<"group">> => <<"g1">>}
 }).
 
 -define(SERVER_RESPONSE_JSON(Result), ?SERVER_RESPONSE_JSON(Result, false)).
@@ -533,7 +534,7 @@ samples() ->
                 {ok, Req, State}
             end,
             config_params => #{},
-            result => {ok, #{is_superuser => false, user_property => #{}}}
+            result => {ok, #{is_superuser => false, client_attrs => #{}}}
         },
 
         %% get request with json body response
@@ -542,13 +543,20 @@ samples() ->
                 Req = cowboy_req:reply(
                     200,
                     #{<<"content-type">> => <<"application/json">>},
-                    emqx_utils_json:encode(#{result => allow, is_superuser => true}),
+                    emqx_utils_json:encode(#{
+                        result => allow,
+                        is_superuser => true,
+                        client_attrs => #{
+                            fid => <<"n11">>,
+                            <<"#_bad_key">> => <<"v">>
+                        }
+                    }),
                     Req0
                 ),
                 {ok, Req, State}
             end,
             config_params => #{},
-            result => {ok, #{is_superuser => true, user_property => #{}}}
+            result => {ok, #{is_superuser => true, client_attrs => #{<<"fid">> => <<"n11">>}}}
         },
 
         %% get request with url-form-encoded body response
@@ -566,7 +574,7 @@ samples() ->
                 {ok, Req, State}
             end,
             config_params => #{},
-            result => {ok, #{is_superuser => true, user_property => #{}}}
+            result => {ok, #{is_superuser => true, client_attrs => #{}}}
         },
 
         %% get request with response of unknown encoding
@@ -608,7 +616,7 @@ samples() ->
                 <<"method">> => <<"post">>,
                 <<"headers">> => #{<<"content-type">> => <<"application/json">>}
             },
-            result => {ok, #{is_superuser => false, user_property => #{}}}
+            result => {ok, #{is_superuser => false, client_attrs => #{}}}
         },
 
         %% simple post request, application/x-www-form-urlencoded
@@ -634,7 +642,7 @@ samples() ->
                         <<"application/x-www-form-urlencoded">>
                 }
             },
-            result => {ok, #{is_superuser => false, user_property => #{}}}
+            result => {ok, #{is_superuser => false, client_attrs => #{}}}
         },
 
         %% simple post request for placeholders, application/json
@@ -647,7 +655,8 @@ samples() ->
                     <<"clientid">> := <<"clienta">>,
                     <<"peerhost">> := <<"127.0.0.1">>,
                     <<"cert_subject">> := <<"cert_subject_data">>,
-                    <<"cert_common_name">> := <<"cert_common_name_data">>
+                    <<"cert_common_name">> := <<"cert_common_name_data">>,
+                    <<"the_group">> := <<"g1">>
                 } = emqx_utils_json:decode(RawBody, [return_maps]),
                 Req = cowboy_req:reply(
                     200,
@@ -666,10 +675,11 @@ samples() ->
                     <<"password">> => ?PH_PASSWORD,
                     <<"peerhost">> => ?PH_PEERHOST,
                     <<"cert_subject">> => ?PH_CERT_SUBJECT,
-                    <<"cert_common_name">> => ?PH_CERT_CN_NAME
+                    <<"cert_common_name">> => ?PH_CERT_CN_NAME,
+                    <<"the_group">> => <<"${client_attrs.group}">>
                 }
             },
-            result => {ok, #{is_superuser => false, user_property => #{}}}
+            result => {ok, #{is_superuser => false, client_attrs => #{}}}
         },
 
         %% custom headers

+ 1 - 1
apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_auth_jwt, [
     {description, "EMQX JWT Authentication and Authorization"},
-    {vsn, "0.2.0"},
+    {vsn, "0.3.0"},
     {registered, []},
     {mod, {emqx_auth_jwt_app, []}},
     {applications, [

+ 17 - 15
apps/emqx_auth_jwt/src/emqx_authn_jwt.erl

@@ -219,8 +219,12 @@ verify(undefined, _, _, _) ->
 verify(JWT, JWKs, VerifyClaims, AclClaimName) ->
     case do_verify(JWT, JWKs, VerifyClaims) of
         {ok, Extra} ->
+            IsSuperuser = emqx_authn_utils:is_superuser(Extra),
+            Attrs = emqx_authn_utils:client_attrs(Extra),
             try
-                {ok, acl(Extra, AclClaimName)}
+                ACL = acl(Extra, AclClaimName),
+                Result = maps:merge(IsSuperuser, maps:merge(ACL, Attrs)),
+                {ok, Result}
             catch
                 throw:{bad_acl_rule, Reason} ->
                     %% it's a invalid token, so ok to log
@@ -242,20 +246,18 @@ verify(JWT, JWKs, VerifyClaims, AclClaimName) ->
     end.
 
 acl(Claims, AclClaimName) ->
-    Acl =
-        case Claims of
-            #{AclClaimName := Rules} ->
-                #{
-                    acl => #{
-                        rules => parse_rules(Rules),
-                        source_for_logging => jwt,
-                        expire => maps:get(<<"exp">>, Claims, undefined)
-                    }
-                };
-            _ ->
-                #{}
-        end,
-    maps:merge(emqx_authn_utils:is_superuser(Claims), Acl).
+    case Claims of
+        #{AclClaimName := Rules} ->
+            #{
+                acl => #{
+                    rules => parse_rules(Rules),
+                    source_for_logging => jwt,
+                    expire => maps:get(<<"exp">>, Claims, undefined)
+                }
+            };
+        _ ->
+            #{}
+    end.
 
 do_verify(_JWT, [], _VerifyClaims) ->
     {error, invalid_signature};

+ 1 - 1
apps/emqx_auth_mongodb/src/emqx_auth_mongodb.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_auth_mongodb, [
     {description, "EMQX MongoDB Authentication and Authorization"},
-    {vsn, "0.1.1"},
+    {vsn, "0.2.0"},
     {registered, []},
     {mod, {emqx_auth_mongodb_app, []}},
     {applications, [

+ 2 - 1
apps/emqx_auth_mongodb/src/emqx_authz_mongodb.erl

@@ -40,7 +40,8 @@
     ?VAR_CLIENTID,
     ?VAR_PEERHOST,
     ?VAR_CERT_CN_NAME,
-    ?VAR_CERT_SUBJECT
+    ?VAR_CERT_SUBJECT,
+    ?VAR_NS_CLIENT_ATTRS
 ]).
 
 description() ->

+ 1 - 1
apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_auth_mysql, [
     {description, "EMQX MySQL Authentication and Authorization"},
-    {vsn, "0.1.2"},
+    {vsn, "0.2.0"},
     {registered, []},
     {mod, {emqx_auth_mysql_app, []}},
     {applications, [

+ 2 - 1
apps/emqx_auth_mysql/src/emqx_authz_mysql.erl

@@ -42,7 +42,8 @@
     ?VAR_CLIENTID,
     ?VAR_PEERHOST,
     ?VAR_CERT_CN_NAME,
-    ?VAR_CERT_SUBJECT
+    ?VAR_CERT_SUBJECT,
+    ?VAR_NS_CLIENT_ATTRS
 ]).
 
 description() ->

+ 1 - 1
apps/emqx_auth_postgresql/src/emqx_auth_postgresql.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_auth_postgresql, [
     {description, "EMQX PostgreSQL Authentication and Authorization"},
-    {vsn, "0.1.1"},
+    {vsn, "0.2.0"},
     {registered, []},
     {mod, {emqx_auth_postgresql_app, []}},
     {applications, [

+ 2 - 1
apps/emqx_auth_postgresql/src/emqx_authz_postgresql.erl

@@ -42,7 +42,8 @@
     ?VAR_CLIENTID,
     ?VAR_PEERHOST,
     ?VAR_CERT_CN_NAME,
-    ?VAR_CERT_SUBJECT
+    ?VAR_CERT_SUBJECT,
+    ?VAR_NS_CLIENT_ATTRS
 ]).
 
 description() ->

+ 1 - 1
apps/emqx_auth_redis/src/emqx_auth_redis.app.src

@@ -1,7 +1,7 @@
 %% -*- mode: erlang -*-
 {application, emqx_auth_redis, [
     {description, "EMQX Redis Authentication and Authorization"},
-    {vsn, "0.1.2"},
+    {vsn, "0.2.0"},
     {registered, []},
     {mod, {emqx_auth_redis_app, []}},
     {applications, [

+ 2 - 1
apps/emqx_auth_redis/src/emqx_authz_redis.erl

@@ -40,7 +40,8 @@
     ?VAR_CERT_SUBJECT,
     ?VAR_PEERHOST,
     ?VAR_CLIENTID,
-    ?VAR_USERNAME
+    ?VAR_USERNAME,
+    ?VAR_NS_CLIENT_ATTRS
 ]).
 
 description() ->

+ 2 - 2
apps/emqx_conf/src/emqx_conf.erl

@@ -424,14 +424,14 @@ is_missing_namespace(ShortName, FullName, RootNames) ->
             ShortName =:= FullName
     end.
 
-%% Returns short name from full name, fullname delemited by colon(:).
+%% Returns short name from full name, fullname delimited by colon(:).
 short_name(FullName) ->
     case string:split(FullName, ":") of
         [_, Name] -> to_bin(Name);
         _ -> to_bin(FullName)
     end.
 
-%% Returns the hash-anchor from full name, fullname delemited by colon(:).
+%% Returns the hash-anchor from full name, fullname delimited by colon(:).
 format_hash(FullName) ->
     case string:split(FullName, ":") of
         [Namespace, Name] ->

+ 42 - 2
apps/emqx_utils/src/emqx_template.erl

@@ -32,6 +32,7 @@
 -export([lookup/2]).
 
 -export([to_string/1]).
+-export([escape_disallowed/2]).
 
 -export_type([t/0]).
 -export_type([str/0]).
@@ -145,18 +146,57 @@ parse_accessor(Var) ->
 %% @doc Validate a template against a set of allowed variables.
 %% If the given template contains any variable not in the allowed set, an error
 %% is returned.
--spec validate([varname()], t()) ->
+-spec validate([varname() | {var_namespace, varname()}], t()) ->
     ok | {error, [_Error :: {varname(), disallowed}]}.
 validate(Allowed, Template) ->
     {_, Errors} = render(Template, #{}),
     {Used, _} = lists:unzip(Errors),
-    case lists:usort(Used) -- Allowed of
+    case find_disallowed(lists:usort(Used), Allowed) of
         [] ->
             ok;
         Disallowed ->
             {error, [{Var, disallowed} || Var <- Disallowed]}
     end.
 
+%% @doc Escape `$' with `${$}' for the variable references
+%% which are not allowed, so the original variable name
+%% can be preserved instead of rendered as `undefined'.
+%% E.g. to render `${var1}/${clientid}', if only `clientid'
+%% is allowed, the rendering result should be `${var1}/client1'
+%% but not `undefined/client1'.
+escape_disallowed(Template, Allowed) ->
+    {Result, _} = render(Template, #{}, #{
+        var_trans => fun(Name, _) ->
+            case is_allowed(Name, Allowed) of
+                true -> "${" ++ Name ++ "}";
+                false -> "${$}{" ++ Name ++ "}"
+            end
+        end
+    }),
+    Result.
+
+find_disallowed(Vars, Allowed) ->
+    lists:filter(fun(Var) -> not is_allowed(Var, Allowed) end, Vars).
+
+%% @private Return 'true' if a variable reference matches
+%% at least one allowed variables.
+%% For `"${var_name}"' kind of reference, its a `=:=' compare
+%% for `{var_namespace, "namespace"}' kind of reference
+%% it matches the `"namespace."' prefix.
+is_allowed(_Var, []) ->
+    false;
+is_allowed(Var, [{var_namespace, VarPrefix} | Allowed]) ->
+    case lists:prefix(VarPrefix ++ ".", Var) of
+        true ->
+            true;
+        false ->
+            is_allowed(Var, Allowed)
+    end;
+is_allowed(Var, [Var | _Allowed]) ->
+    true;
+is_allowed(Var, [_ | Allowed]) ->
+    is_allowed(Var, Allowed).
+
 %% @doc Check if a template is constant with respect to rendering, i.e. does not
 %% contain any placeholders.
 -spec is_const(t()) ->

+ 9 - 1
apps/emqx_utils/src/emqx_utils.erl

@@ -67,7 +67,8 @@
     format/1,
     call_first_defined/1,
     ntoa/1,
-    foldl_while/3
+    foldl_while/3,
+    is_restricted_str/1
 ]).
 
 -export([
@@ -861,6 +862,13 @@ ntoa({0, 0, 0, 0, 0, 16#ffff, AB, CD}) ->
 ntoa(IP) ->
     inet_parse:ntoa(IP).
 
+%% @doc Return true if the provided string is a restricted string:
+%% Start with a letter or a digit,
+%% remaining characters can be '-' or '_' in addition to letters and digits
+is_restricted_str(String) ->
+    RE = <<"^[A-Za-z0-9]+[A-Za-z0-9-_]*$">>,
+    match =:= re:run(String, RE, [{capture, none}]).
+
 -ifdef(TEST).
 -include_lib("eunit/include/eunit.hrl").
 

+ 12 - 0
apps/emqx_utils/test/emqx_template_SUITE.erl

@@ -337,6 +337,18 @@ t_unparse_tmpl_deep(_) ->
     Template = emqx_template:parse_deep(Term),
     ?assertEqual(Term, emqx_template:unparse(Template)).
 
+t_allow_var_by_namespace(_) ->
+    Context = #{d => #{d1 => <<"hi">>}},
+    Template = emqx_template:parse(<<"d.d1:${d.d1}">>),
+    ?assertEqual(
+        ok,
+        emqx_template:validate([{var_namespace, "d"}], Template)
+    ),
+    ?assertEqual(
+        {<<"d.d1:hi">>, []},
+        render_string(Template, Context)
+    ).
+
 %%
 
 render_string(Template, Context) ->

+ 37 - 0
changes/ce/feat-12750.en.md

@@ -0,0 +1,37 @@
+Customizable client attributes in `clientinfo`.
+
+Introduced a new field `client_attrs` in the `clientinfo` object.
+This enhancement enables the initialization of `client_attrs` with specific
+attributes derived from the `clientinfo` fields, immediately up on accepting
+an MQTT connection.
+
+### Initialization of `client_attrs`
+
+- The `client_attrs` field can be initially populated based on the configuration from one of the
+  following sources:
+  - `cn`: The common name from the TLS client's certificate.
+  - `dn`: The distinguished name from the TLS client's certificate, that is, the certificate "Subject".
+  - `clientid`: The MQTT client ID provided by the client.
+  - `username`: The username provided by the client.
+  - `user_property`: Extract a property value from 'User-Property' of the MQTT CONNECT packet.
+
+### Extension through Authentication Responses
+
+- Additional attributes may be merged into `client_attrs` from authentication responses. Supported
+  authentication backends include:
+  - **HTTP**: Attributes can be included in the JSON object of the HTTP response body through a
+    `client_attrs` field.
+  - **JWT**: Attributes can be included via a `client_attrs` claim within the JWT.
+
+### Usage in Authentication and Authorization
+
+- If `client_attrs` is initialized before authentication, it can be used in external authentication
+  requests. For instance, `${client_attrs.property1}` can be used within request templates
+  directed at an HTTP server for the purpose of authenticity validation.
+
+- The `client_attrs` can be utilized in authorization configurations or request templates, enhancing
+  flexibility and control. Examples include:
+  - In `acl.conf`, use `{allow, all, all, ["${client_attrs.namespace}/#"]}` to apply permissions
+    based on the `namespace` attribute.
+  - In other authorization backends, `${client_attrs.namespace}` can be used within request templates
+    to dynamically include client attributes.

+ 1 - 1
rel/i18n/emqx_mgmt_api_trace.hocon

@@ -56,7 +56,7 @@ file_mtime.label:
 """file mtime"""
 
 trace_name.desc:
-"""Unique name of the trace. Only ascii letters in a-z, A-Z, 0-9 and underscore '_' are allowed."""
+"""Unique name of the trace. Only ASCII letters in a-z, A-Z, 0-9 and underscore '_' are allowed."""
 trace_name.label:
 """Unique name of the trace"""
 

+ 50 - 5
rel/i18n/emqx_schema.hocon

@@ -1565,12 +1565,57 @@ This value specifies size of the batch.
 
 Note: larger batches generally improve the throughput and overall performance of the system, but increase RAM usage per client."""
 
-durable_storage.label:
-"""Durable storage"""
+durable_storage.label:"Durable storage"
+durable_storage.desc: """~
+    Configuration related to the EMQX durable storages.
+
+    EMQX uses durable storages to offload various data, such as MQTT messages, to disc."""
+
+client_attrs_init {
+  label: "Client Attributes Initialization"
+  desc: """~
+    Specify how to initialize client attributes.
+    One initial client attribute can be initialized as `client_attrs.NAME`,
+    where `NAME` is the name of the attribute specified in the config `extract_as`.
+    The initialized client attribute will be stored in the `client_attrs` property with the specified name,
+    and can be used as a placeholder in a template for authentication and authorization.
+    For example, use `${client_attrs.alias}` to render an HTTP POST body when `extract_as = alias`,
+    or render listener config `moutpoint = devices/${client_attrs.alias}/` to initialize a per-client topic namespace."""
+}
 
-durable_storage.desc:
-"""Configuration related to the EMQX durable storages.
+client_attrs_init_extract_from {
+  label: "Client Property to Extract Attribute"
+  desc: """~
+    Specify from which client property the client attribute should be extracted.
+
+    Supported values:
+    - `clientid`: Extract from the client ID.
+    - `username`: Extract from the username.
+    - `cn`: Extract from the Common Name (CN) field of the client certificate.
+    - `dn`: Extract from the Distinguished Name (DN) field of the client certificate.
+    - `user_property`: Extract from the user property sent in the MQTT v5 `CONNECT` packet.
+      In this case, `extract_regexp` is not applicable, and `extract_as` should be the user property key.
+
+    NOTE: this extraction happens **after** `clientid` or `username` is initialized
+    from `peer_cert_as_clientid` or `peer_cert_as_username` config."""
+}
 
-EMQX uses durable storages to offload various data, such as MQTT messages, to disc."""
+client_attrs_init_extract_regexp {
+  label: "Client Attribute Extraction Regular Expression"
+  desc: """~
+    The regular expression to extract a client attribute from the client property specified by `client_attrs_init.extract_from` config.
+    The expression should match the entire client property value, and capturing groups are concatenated to make the client attribute.
+    For example if the client attribute is the first part of the client ID delimited by a dash, the regular expression would be `^(.+?)-.*$`.
+    Note that failure to match the regular expression will result in the client attribute being absent but not an empty string.
+    Note also that currently only printable ASCII characters are allowed as input for the regular expression extraction."""
+}
+
+client_attrs_init_extract_as {
+  label: "Name The Extracted Attribute"
+  desc: """~
+    The name of the client attribute extracted from the client property specified by `client_attrs_init.extract_from` config.
+    The extracted attribute will be stored in the `client_attrs` property with this name.
+    In case `extract_from = user_property`, this should be the key of the user property."""
+}
 
 }