diff --git a/apps/emqx/include/emqx_placeholder.hrl b/apps/emqx/include/emqx_placeholder.hrl index 8e886fe68..31ce6a070 100644 --- a/apps/emqx/include/emqx_placeholder.hrl +++ b/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)). diff --git a/apps/emqx/src/emqx_channel.erl b/apps/emqx/src/emqx_channel.erl index 0a0f37bcb..4271666b8 100644 --- a/apps/emqx/src/emqx_channel.erl +++ b/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 diff --git a/apps/emqx/src/emqx_cm.erl b/apps/emqx/src/emqx_cm.erl index 421452554..938a55efa 100644 --- a/apps/emqx/src/emqx_cm.erl +++ b/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 diff --git a/apps/emqx/src/emqx_mountpoint.erl b/apps/emqx/src/emqx_mountpoint.erl index 42e44c176..9d2790b9e 100644 --- a/apps/emqx/src/emqx_mountpoint.erl +++ b/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). -feed_var({_PlaceHolder, undefined}, MountPoint) -> - MountPoint; -feed_var({PlaceHolder, Value}, MountPoint) -> - emqx_topic:feed_var(PlaceHolder, Value, MountPoint). +lookup([<>], #{clientid := ClientId}) when is_binary(ClientId) -> + {ok, ClientId}; +lookup([<>], #{username := Username}) when is_binary(Username) -> + {ok, Username}; +lookup([<>], #{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. diff --git a/apps/emqx/src/emqx_schema.erl b/apps/emqx/src/emqx_schema.erl index 7889a13de..427df5db0 100644 --- a/apps/emqx/src/emqx_schema.erl +++ b/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. diff --git a/apps/emqx/src/emqx_types.erl b/apps/emqx/src/emqx_types.erl index 99a71e20b..322cc1c05 100644 --- a/apps/emqx/src/emqx_types.erl +++ b/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()}]. diff --git a/apps/emqx/test/emqx_client_SUITE.erl b/apps/emqx/test/emqx_client_SUITE.erl index d9218b0dd..ba38d92ff 100644 --- a/apps/emqx/test/emqx_client_SUITE.erl +++ b/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). diff --git a/apps/emqx/test/emqx_config_SUITE.erl b/apps/emqx/test/emqx_config_SUITE.erl index c2dc22288..b3e60f793 100644 --- a/apps/emqx/test/emqx_config_SUITE.erl +++ b/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 => #{ diff --git a/apps/emqx/test/emqx_listeners_SUITE.erl b/apps/emqx/test/emqx_listeners_SUITE.erl index 78c2c2e3a..acd7656d7 100644 --- a/apps/emqx/test/emqx_listeners_SUITE.erl +++ b/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 = <>, + 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 = #{ diff --git a/apps/emqx/test/emqx_mountpoint_SUITE.erl b/apps/emqx/test/emqx_mountpoint_SUITE.erl index 06913a35c..756bfeb59 100644 --- a/apps/emqx/test/emqx_mountpoint_SUITE.erl +++ b/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">> + } + ) ). diff --git a/apps/emqx_auth/src/emqx_authn/emqx_authn_utils.erl b/apps/emqx_auth/src/emqx_authn/emqx_authn_utils.erl index bf17ca950..a2050fbf0 100644 --- a/apps/emqx_auth/src/emqx_authn/emqx_authn_utils.erl +++ b/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; diff --git a/apps/emqx_auth/src/emqx_authz/emqx_authz_rule.erl b/apps/emqx_auth/src/emqx_authz/emqx_authz_rule.erl index 9ecfa0f64..9a877b5c8 100644 --- a/apps/emqx_auth/src/emqx_authz/emqx_authz_rule.erl +++ b/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} diff --git a/apps/emqx_auth/src/emqx_authz/emqx_authz_utils.erl b/apps/emqx_auth/src/emqx_authz/emqx_authz_utils.erl index 9a795d434..26b8d000f 100644 --- a/apps/emqx_auth/src/emqx_authz/emqx_authz_utils.erl +++ b/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. diff --git a/apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl b/apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl index c9d5933bb..c88dcc244 100644 --- a/apps/emqx_auth/test/emqx_authz/emqx_authz_SUITE.erl +++ b/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. diff --git a/apps/emqx_auth/test/emqx_authz/emqx_authz_file_SUITE.erl b/apps/emqx_auth/test/emqx_authz/emqx_authz_file_SUITE.erl index 3ed7c1f83..e51f6dd37 100644 --- a/apps/emqx_auth/test/emqx_authz/emqx_authz_file_SUITE.erl +++ b/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(), diff --git a/apps/emqx_auth_http/src/emqx_authn_http.erl b/apps/emqx_auth_http/src/emqx_authn_http.erl index 6e8bf0514..5cbffb394 100644 --- a/apps/emqx_auth_http/src/emqx_authn_http.erl +++ b/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">> -> diff --git a/apps/emqx_auth_http/src/emqx_authz_http.erl b/apps/emqx_auth_http/src/emqx_authz_http.erl index 721307099..ffc339393 100644 --- a/apps/emqx_auth_http/src/emqx_authz_http.erl +++ b/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, [ diff --git a/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl b/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl index 3be69641b..54e1f4b11 100644 --- a/apps/emqx_auth_http/test/emqx_authn_http_SUITE.erl +++ b/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 diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src index 7e313881e..62d560d2f 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src +++ b/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, [ diff --git a/apps/emqx_auth_jwt/src/emqx_authn_jwt.erl b/apps/emqx_auth_jwt/src/emqx_authn_jwt.erl index 5f89d076d..2a12f5acf 100644 --- a/apps/emqx_auth_jwt/src/emqx_authn_jwt.erl +++ b/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}; diff --git a/apps/emqx_auth_mongodb/src/emqx_auth_mongodb.app.src b/apps/emqx_auth_mongodb/src/emqx_auth_mongodb.app.src index 8970329fe..df3cc1268 100644 --- a/apps/emqx_auth_mongodb/src/emqx_auth_mongodb.app.src +++ b/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, [ diff --git a/apps/emqx_auth_mongodb/src/emqx_authz_mongodb.erl b/apps/emqx_auth_mongodb/src/emqx_authz_mongodb.erl index 8f185c3bd..0bab6ef90 100644 --- a/apps/emqx_auth_mongodb/src/emqx_authz_mongodb.erl +++ b/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() -> diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src b/apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src index 24fdd2648..29bf97ac8 100644 --- a/apps/emqx_auth_mysql/src/emqx_auth_mysql.app.src +++ b/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, [ diff --git a/apps/emqx_auth_mysql/src/emqx_authz_mysql.erl b/apps/emqx_auth_mysql/src/emqx_authz_mysql.erl index 6356f0017..59ed878ab 100644 --- a/apps/emqx_auth_mysql/src/emqx_authz_mysql.erl +++ b/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() -> diff --git a/apps/emqx_auth_postgresql/src/emqx_auth_postgresql.app.src b/apps/emqx_auth_postgresql/src/emqx_auth_postgresql.app.src index bae3da0cb..3978f7dbc 100644 --- a/apps/emqx_auth_postgresql/src/emqx_auth_postgresql.app.src +++ b/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, [ diff --git a/apps/emqx_auth_postgresql/src/emqx_authz_postgresql.erl b/apps/emqx_auth_postgresql/src/emqx_authz_postgresql.erl index f53307d24..a77f0a424 100644 --- a/apps/emqx_auth_postgresql/src/emqx_authz_postgresql.erl +++ b/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() -> diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis.app.src b/apps/emqx_auth_redis/src/emqx_auth_redis.app.src index b5669e706..74168495b 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis.app.src +++ b/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, [ diff --git a/apps/emqx_auth_redis/src/emqx_authz_redis.erl b/apps/emqx_auth_redis/src/emqx_authz_redis.erl index a8f52da1b..a7f88f7c6 100644 --- a/apps/emqx_auth_redis/src/emqx_authz_redis.erl +++ b/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() -> diff --git a/apps/emqx_conf/src/emqx_conf.erl b/apps/emqx_conf/src/emqx_conf.erl index b685db278..122998eeb 100644 --- a/apps/emqx_conf/src/emqx_conf.erl +++ b/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] -> diff --git a/apps/emqx_utils/src/emqx_template.erl b/apps/emqx_utils/src/emqx_template.erl index 9a2ac1b8f..1383f90e1 100644 --- a/apps/emqx_utils/src/emqx_template.erl +++ b/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()) -> diff --git a/apps/emqx_utils/src/emqx_utils.erl b/apps/emqx_utils/src/emqx_utils.erl index 12855b9b6..007c2b54b 100644 --- a/apps/emqx_utils/src/emqx_utils.erl +++ b/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"). diff --git a/apps/emqx_utils/test/emqx_template_SUITE.erl b/apps/emqx_utils/test/emqx_template_SUITE.erl index 46b42e5db..0a3273170 100644 --- a/apps/emqx_utils/test/emqx_template_SUITE.erl +++ b/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) -> diff --git a/changes/ce/feat-12750.en.md b/changes/ce/feat-12750.en.md new file mode 100644 index 000000000..bd7375168 --- /dev/null +++ b/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. diff --git a/rel/i18n/emqx_mgmt_api_trace.hocon b/rel/i18n/emqx_mgmt_api_trace.hocon index a53dabae3..67462ab43 100644 --- a/rel/i18n/emqx_mgmt_api_trace.hocon +++ b/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""" diff --git a/rel/i18n/emqx_schema.hocon b/rel/i18n/emqx_schema.hocon index 0a0f71cfe..0bd8c74d5 100644 --- a/rel/i18n/emqx_schema.hocon +++ b/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. -durable_storage.desc: -"""Configuration related to the EMQX durable storages. + EMQX uses durable storages to offload various data, such as MQTT messages, to disc.""" -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.""" +} }