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

0316 introduce client_attrs
This commit is contained in:
Zaiming (Stone) Shi 2024-03-27 16:19:47 +01:00 committed by GitHub
commit beb9152d50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 621 additions and 96 deletions

View File

@ -31,11 +31,14 @@
-define(PH_CERT_SUBJECT, ?PH(?VAR_CERT_SUBJECT)). -define(PH_CERT_SUBJECT, ?PH(?VAR_CERT_SUBJECT)).
-define(PH_CERT_CN_NAME, ?PH(?VAR_CERT_CN_NAME)). -define(PH_CERT_CN_NAME, ?PH(?VAR_CERT_CN_NAME)).
%% MQTT %% MQTT/Gateway
-define(VAR_PASSWORD, "password"). -define(VAR_PASSWORD, "password").
-define(VAR_CLIENTID, "clientid"). -define(VAR_CLIENTID, "clientid").
-define(VAR_USERNAME, "username"). -define(VAR_USERNAME, "username").
-define(VAR_TOPIC, "topic"). -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_PASSWORD, ?PH(?VAR_PASSWORD)).
-define(PH_CLIENTID, ?PH(?VAR_CLIENTID)). -define(PH_CLIENTID, ?PH(?VAR_CLIENTID)).
-define(PH_FROM_CLIENTID, ?PH("from_clientid")). -define(PH_FROM_CLIENTID, ?PH("from_clientid")).
@ -89,7 +92,7 @@
-define(PH_NODE, ?PH("node")). -define(PH_NODE, ?PH("node")).
-define(PH_REASON, ?PH("reason")). -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(VAR_RETAIN, "retain").
-define(PH_RETAIN, ?PH(?VAR_RETAIN)). -define(PH_RETAIN, ?PH(?VAR_RETAIN)).

View File

@ -251,7 +251,7 @@ init(
MP -> MP MP -> MP
end, end,
ListenerId = emqx_listeners:listener_id(Type, Listener), ListenerId = emqx_listeners:listener_id(Type, Listener),
ClientInfo = set_peercert_infos( ClientInfo0 = set_peercert_infos(
Peercert, Peercert,
#{ #{
zone => Zone, zone => Zone,
@ -269,6 +269,8 @@ init(
}, },
Zone 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), {NClientInfo, NConnInfo} = take_ws_cookie(ClientInfo, ConnInfo),
#channel{ #channel{
conninfo = NConnInfo, conninfo = NConnInfo,
@ -1570,7 +1572,8 @@ enrich_client(ConnPkt, Channel = #channel{clientinfo = ClientInfo}) ->
fun set_bridge_mode/2, fun set_bridge_mode/2,
fun maybe_username_as_clientid/2, fun maybe_username_as_clientid/2,
fun maybe_assign_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, ConnPkt,
ClientInfo ClientInfo
@ -1582,6 +1585,47 @@ enrich_client(ConnPkt, Channel = #channel{clientinfo = ClientInfo}) ->
{error, ReasonCode, Channel#channel{clientinfo = NClientInfo}} {error, ReasonCode, Channel#channel{clientinfo = NClientInfo}}
end. 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( set_username(
#mqtt_packet_connect{username = Username}, #mqtt_packet_connect{username = Username},
ClientInfo = #{username := undefined} ClientInfo = #{username := undefined}
@ -1622,11 +1666,81 @@ maybe_assign_clientid(#mqtt_packet_connect{clientid = <<>>}, ClientInfo) ->
maybe_assign_clientid(#mqtt_packet_connect{clientid = ClientId}, ClientInfo) -> maybe_assign_clientid(#mqtt_packet_connect{clientid = ClientId}, ClientInfo) ->
{ok, ClientInfo#{clientid => ClientId}}. {ok, ClientInfo#{clientid => ClientId}}.
fix_mountpoint(_ConnPkt, #{mountpoint := undefined}) -> maybe_set_client_initial_attr(ConnPkt, #{zone := Zone} = ClientInfo0) ->
ok; Config = get_mqtt_conf(Zone, client_attrs_init),
fix_mountpoint(_ConnPkt, ClientInfo = #{mountpoint := MountPoint}) -> 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), MountPoint1 = emqx_mountpoint:replvar(MountPoint, ClientInfo),
{ok, ClientInfo#{mountpoint := MountPoint1}}. ClientInfo#{mountpoint := MountPoint1}.
%%-------------------------------------------------------------------- %%--------------------------------------------------------------------
%% Set log metadata %% Set log metadata
@ -1735,9 +1849,23 @@ do_authenticate(Credential, #channel{clientinfo = ClientInfo} = Channel) ->
{error, emqx_reason_codes:connack_error(Reason)} {error, emqx_reason_codes:connack_error(Reason)}
end. end.
merge_auth_result(ClientInfo, AuthResult) when is_map(ClientInfo) andalso is_map(AuthResult) -> %% Merge authentication result into ClientInfo
IsSuperuser = maps:get(is_superuser, AuthResult, false), %% Authentication result may include:
maps:merge(ClientInfo, AuthResult#{is_superuser => IsSuperuser}). %% 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 %% Process Topic Alias

View File

@ -227,7 +227,7 @@ get_chan_info(ClientId, ChanPid) ->
wrap_rpc(emqx_cm_proto_v2:get_chan_info(ClientId, ChanPid)). wrap_rpc(emqx_cm_proto_v2:get_chan_info(ClientId, ChanPid)).
%% @doc Update infos of the channel. %% @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) -> set_chan_info(ClientId, Info) when ?IS_CLIENTID(ClientId) ->
Chan = {ClientId, self()}, Chan = {ClientId, self()},
try try

View File

@ -28,10 +28,19 @@
-export([replvar/2]). -export([replvar/2]).
-export([lookup/2]).
-export_type([mountpoint/0]). -export_type([mountpoint/0]).
-type mountpoint() :: binary(). -type mountpoint() :: binary().
-define(ALLOWED_VARS, [
?VAR_CLIENTID,
?VAR_USERNAME,
?VAR_ENDPOINT_NAME,
?VAR_NS_CLIENT_ATTRS
]).
-spec mount(option(mountpoint()), Any) -> Any when -spec mount(option(mountpoint()), Any) -> Any when
Any :: Any ::
emqx_types:topic() emqx_types:topic()
@ -88,17 +97,28 @@ unmount_maybe_share(MountPoint, TopicFilter = #share{topic = Topic}) when
replvar(undefined, _Vars) -> replvar(undefined, _Vars) ->
undefined; undefined;
replvar(MountPoint, Vars) -> replvar(MountPoint, Vars) ->
ClientID = maps:get(clientid, Vars, undefined), Template = parse(MountPoint),
UserName = maps:get(username, Vars, undefined), {String, _Errors} = emqx_template:render(Template, {?MODULE, Vars}),
EndpointName = maps:get(endpoint_name, Vars, undefined), unicode:characters_to_binary(String).
List = [
{?PH_CLIENTID, ClientID},
{?PH_USERNAME, UserName},
{?PH_ENDPOINT_NAME, EndpointName}
],
lists:foldl(fun feed_var/2, MountPoint, List).
feed_var({_PlaceHolder, undefined}, MountPoint) -> lookup([<<?VAR_CLIENTID>>], #{clientid := ClientId}) when is_binary(ClientId) ->
MountPoint; {ok, ClientId};
feed_var({PlaceHolder, Value}, MountPoint) -> lookup([<<?VAR_USERNAME>>], #{username := Username}) when is_binary(Username) ->
emqx_topic:feed_var(PlaceHolder, Value, MountPoint). {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), "}"])}.
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.

View File

@ -1731,7 +1731,28 @@ fields("session_persistence") ->
)} )}
]; ];
fields(durable_storage) -> 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) -> mqtt_listener(Bind) ->
base_listener(Bind) ++ base_listener(Bind) ++
@ -1987,6 +2008,8 @@ desc("session_persistence") ->
"Settings governing durable sessions persistence."; "Settings governing durable sessions persistence.";
desc(durable_storage) -> desc(durable_storage) ->
?DESC(durable_storage); ?DESC(durable_storage);
desc("client_attrs_init") ->
?DESC(client_attrs_init);
desc(_) -> desc(_) ->
undefined. undefined.
@ -3526,6 +3549,14 @@ mqtt_general() ->
default => disabled, default => disabled,
desc => ?DESC(mqtt_peer_cert_as_clientid) 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. %% All session's importance should be lower than general part to organize document.

View File

@ -54,7 +54,8 @@
password/0, password/0,
peerhost/0, peerhost/0,
peername/0, peername/0,
protocol/0 protocol/0,
client_attrs/0
]). ]).
-export_type([ -export_type([
@ -106,7 +107,7 @@
-export_type([ -export_type([
caps/0, caps/0,
attrs/0, channel_attrs/0,
infos/0, infos/0,
stats/0 stats/0
]). ]).
@ -189,8 +190,12 @@
anonymous => boolean(), anonymous => boolean(),
cn => binary(), cn => binary(),
dn => 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() atom() => term()
}. }.
-type client_attrs() :: #{binary() => binary()}.
-type clientid() :: binary() | atom(). -type clientid() :: binary() | atom().
-type username() :: option(binary()). -type username() :: option(binary()).
-type password() :: option(binary()). -type password() :: option(binary()).
@ -270,7 +275,7 @@
-type command() :: #command{}. -type command() :: #command{}.
-type caps() :: emqx_mqtt_caps:caps(). -type caps() :: emqx_mqtt_caps:caps().
-type attrs() :: #{atom() => term()}. -type channel_attrs() :: #{atom() => term()}.
-type infos() :: #{atom() => term()}. -type infos() :: #{atom() => term()}.
-type stats() :: [{atom(), term()}]. -type stats() :: [{atom(), term()}].

View File

@ -75,6 +75,9 @@ groups() ->
{mqttv5, [non_parallel_tests], [t_basic_with_props_v5, t_v5_receive_maximim_in_connack]}, {mqttv5, [non_parallel_tests], [t_basic_with_props_v5, t_v5_receive_maximim_in_connack]},
{others, [non_parallel_tests], [ {others, [non_parallel_tests], [
t_username_as_clientid, 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_default_config_tls,
t_certcn_as_clientid_tlsv1_3, t_certcn_as_clientid_tlsv1_3,
t_certcn_as_clientid_tlsv1_2, t_certcn_as_clientid_tlsv1_2,
@ -384,6 +387,55 @@ t_username_as_clientid(_) ->
end, end,
emqtt:disconnect(C). 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(_) -> t_certcn_as_clientid_default_config_tls(_) ->
tls_certcn_as_clientid(default). tls_certcn_as_clientid(default).

View File

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

View File

@ -143,6 +143,36 @@ t_max_conns_tcp(_Config) ->
) )
end). 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) -> t_current_conns_tcp(_Config) ->
Port = emqx_common_test_helpers:select_free_port(tcp), Port = emqx_common_test_helpers:select_free_port(tcp),
Conf = #{ Conf = #{

View File

@ -116,4 +116,32 @@ t_replvar(_) ->
username => undefined 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">>
}
)
). ).

View File

@ -32,6 +32,7 @@
render_urlencoded_str/2, render_urlencoded_str/2,
render_sql_params/2, render_sql_params/2,
is_superuser/1, is_superuser/1,
client_attrs/1,
bin/1, bin/1,
ensure_apps_started/1, ensure_apps_started/1,
cleanup_resources/0, cleanup_resources/0,
@ -45,13 +46,16 @@
default_headers_no_content_type/0 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, [ -define(ALLOWED_VARS, [
?VAR_USERNAME, ?VAR_USERNAME,
?VAR_CLIENTID, ?VAR_CLIENTID,
?VAR_PASSWORD, ?VAR_PASSWORD,
?VAR_PEERHOST, ?VAR_PEERHOST,
?VAR_CERT_SUBJECT, ?VAR_CERT_SUBJECT,
?VAR_CERT_CN_NAME ?VAR_CERT_CN_NAME,
?VAR_NS_CLIENT_ATTRS
]). ]).
-define(DEFAULT_RESOURCE_OPTS, #{ -define(DEFAULT_RESOURCE_OPTS, #{
@ -204,6 +208,27 @@ is_superuser(#{<<"is_superuser">> := Value}) ->
is_superuser(#{}) -> is_superuser(#{}) ->
#{is_superuser => false}. #{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) -> ensure_apps_started(bcrypt) ->
{ok, _} = application:ensure_all_started(bcrypt), {ok, _} = application:ensure_all_started(bcrypt),
ok; ok;

View File

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

View File

@ -139,7 +139,7 @@ handle_disallowed_placeholders(Template, Source, Allowed) ->
" However, consider using `${$}` escaping for literal `$` where" " However, consider using `${$}` escaping for literal `$` where"
" needed to avoid unexpected results." " needed to avoid unexpected results."
}), }),
Result = prerender_disallowed_placeholders(Template, Allowed), Result = emqx_template:escape_disallowed(Template, Allowed),
case Source of case Source of
{string, _} -> {string, _} ->
emqx_template:parse(Result); emqx_template:parse(Result);
@ -148,20 +148,6 @@ handle_disallowed_placeholders(Template, Source, Allowed) ->
end end
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) -> render_deep(Template, Values) ->
% NOTE % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string. % Ignoring errors here, undefined bindings will be replaced with empty string.

View File

@ -19,6 +19,7 @@
-compile(export_all). -compile(export_all).
-include("emqx_authz.hrl"). -include("emqx_authz.hrl").
-include_lib("emqx/include/emqx_mqtt.hrl").
-include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/emqx.hrl").
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include_lib("common_test/include/ct.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 %% Testcases
%%------------------------------------------------------------------------------ %%------------------------------------------------------------------------------
@ -544,6 +555,26 @@ t_publish_last_will_testament_denied_topic(_Config) ->
ok. 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, %% client is allowed by ACL to publish to its LWT topic, is connected,
%% and then gets banned and kicked out while connected. Should not %% and then gets banned and kicked out while connected. Should not
%% publish LWT. %% publish LWT.

View File

@ -74,6 +74,30 @@ t_ok(_Config) ->
emqx_access_control:authorize(ClientInfo, ?AUTHZ_SUBSCRIBE, <<"t">>) 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) -> t_rich_actions(_Config) ->
ClientInfo = emqx_authz_test_lib:base_client_info(), ClientInfo = emqx_authz_test_lib:base_client_info(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@
-export([lookup/2]). -export([lookup/2]).
-export([to_string/1]). -export([to_string/1]).
-export([escape_disallowed/2]).
-export_type([t/0]). -export_type([t/0]).
-export_type([str/0]). -export_type([str/0]).
@ -145,18 +146,57 @@ parse_accessor(Var) ->
%% @doc Validate a template against a set of allowed variables. %% @doc Validate a template against a set of allowed variables.
%% If the given template contains any variable not in the allowed set, an error %% If the given template contains any variable not in the allowed set, an error
%% is returned. %% is returned.
-spec validate([varname()], t()) -> -spec validate([varname() | {var_namespace, varname()}], t()) ->
ok | {error, [_Error :: {varname(), disallowed}]}. ok | {error, [_Error :: {varname(), disallowed}]}.
validate(Allowed, Template) -> validate(Allowed, Template) ->
{_, Errors} = render(Template, #{}), {_, Errors} = render(Template, #{}),
{Used, _} = lists:unzip(Errors), {Used, _} = lists:unzip(Errors),
case lists:usort(Used) -- Allowed of case find_disallowed(lists:usort(Used), Allowed) of
[] -> [] ->
ok; ok;
Disallowed -> Disallowed ->
{error, [{Var, disallowed} || Var <- Disallowed]} {error, [{Var, disallowed} || Var <- Disallowed]}
end. 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 %% @doc Check if a template is constant with respect to rendering, i.e. does not
%% contain any placeholders. %% contain any placeholders.
-spec is_const(t()) -> -spec is_const(t()) ->

View File

@ -67,7 +67,8 @@
format/1, format/1,
call_first_defined/1, call_first_defined/1,
ntoa/1, ntoa/1,
foldl_while/3 foldl_while/3,
is_restricted_str/1
]). ]).
-export([ -export([
@ -861,6 +862,13 @@ ntoa({0, 0, 0, 0, 0, 16#ffff, AB, CD}) ->
ntoa(IP) -> ntoa(IP) ->
inet_parse: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). -ifdef(TEST).
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").

View File

@ -337,6 +337,18 @@ t_unparse_tmpl_deep(_) ->
Template = emqx_template:parse_deep(Term), Template = emqx_template:parse_deep(Term),
?assertEqual(Term, emqx_template:unparse(Template)). ?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) -> render_string(Template, Context) ->

View File

@ -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.

View File

@ -56,7 +56,7 @@ file_mtime.label:
"""file mtime""" """file mtime"""
trace_name.desc: 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: trace_name.label:
"""Unique name of the trace""" """Unique name of the trace"""

View File

@ -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.""" Note: larger batches generally improve the throughput and overall performance of the system, but increase RAM usage per client."""
durable_storage.label: durable_storage.label:"Durable storage"
"""Durable storage""" durable_storage.desc: """~
Configuration related to the EMQX durable storages.
durable_storage.desc: EMQX uses durable storages to offload various data, such as MQTT messages, to disc."""
"""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."""
}
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."""
}
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."""
}
} }