Merge pull request #10442 from keynslug/ft/EMQX-9257/placeholder

feat(tpl): split `emqx_placeholder` into a couple of modules
This commit is contained in:
Andrew Mayorov 2023-11-02 22:50:05 +07:00 committed by GitHub
commit 910e81bc41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1903 additions and 652 deletions

View File

@ -19,67 +19,79 @@
-define(PH_VAR_THIS, <<"$_THIS_">>). -define(PH_VAR_THIS, <<"$_THIS_">>).
-define(PH(Type), <<"${", Type/binary, "}">>). -define(PH(Var), <<"${" Var "}">>).
%% action: publish/subscribe %% action: publish/subscribe
-define(PH_ACTION, <<"${action}">>). -define(VAR_ACTION, "action").
-define(PH_ACTION, ?PH(?VAR_ACTION)).
%% cert %% cert
-define(PH_CERT_SUBJECT, <<"${cert_subject}">>). -define(VAR_CERT_SUBJECT, "cert_subject").
-define(PH_CERT_CN_NAME, <<"${cert_common_name}">>). -define(VAR_CERT_CN_NAME, "cert_common_name").
-define(PH_CERT_SUBJECT, ?PH(?VAR_CERT_SUBJECT)).
-define(PH_CERT_CN_NAME, ?PH(?VAR_CERT_CN_NAME)).
%% MQTT %% MQTT
-define(PH_PASSWORD, <<"${password}">>). -define(VAR_PASSWORD, "password").
-define(PH_CLIENTID, <<"${clientid}">>). -define(VAR_CLIENTID, "clientid").
-define(PH_FROM_CLIENTID, <<"${from_clientid}">>). -define(VAR_USERNAME, "username").
-define(PH_USERNAME, <<"${username}">>). -define(VAR_TOPIC, "topic").
-define(PH_FROM_USERNAME, <<"${from_username}">>). -define(PH_PASSWORD, ?PH(?VAR_PASSWORD)).
-define(PH_TOPIC, <<"${topic}">>). -define(PH_CLIENTID, ?PH(?VAR_CLIENTID)).
-define(PH_FROM_CLIENTID, ?PH("from_clientid")).
-define(PH_USERNAME, ?PH(?VAR_USERNAME)).
-define(PH_FROM_USERNAME, ?PH("from_username")).
-define(PH_TOPIC, ?PH(?VAR_TOPIC)).
%% MQTT payload %% MQTT payload
-define(PH_PAYLOAD, <<"${payload}">>). -define(PH_PAYLOAD, ?PH("payload")).
%% client IPAddress %% client IPAddress
-define(PH_PEERHOST, <<"${peerhost}">>). -define(VAR_PEERHOST, "peerhost").
-define(PH_PEERHOST, ?PH(?VAR_PEERHOST)).
%% ip & port %% ip & port
-define(PH_HOST, <<"${host}">>). -define(PH_HOST, ?PH("host")).
-define(PH_PORT, <<"${port}">>). -define(PH_PORT, ?PH("port")).
%% Enumeration of message QoS 0,1,2 %% Enumeration of message QoS 0,1,2
-define(PH_QOS, <<"${qos}">>). -define(VAR_QOS, "qos").
-define(PH_FLAGS, <<"${flags}">>). -define(PH_QOS, ?PH(?VAR_QOS)).
-define(PH_FLAGS, ?PH("flags")).
%% Additional data related to process within the MQTT message %% Additional data related to process within the MQTT message
-define(PH_HEADERS, <<"${headers}">>). -define(PH_HEADERS, ?PH("headers")).
%% protocol name %% protocol name
-define(PH_PROTONAME, <<"${proto_name}">>). -define(VAR_PROTONAME, "proto_name").
-define(PH_PROTONAME, ?PH(?VAR_PROTONAME)).
%% protocol version %% protocol version
-define(PH_PROTOVER, <<"${proto_ver}">>). -define(PH_PROTOVER, ?PH("proto_ver")).
%% MQTT keepalive interval %% MQTT keepalive interval
-define(PH_KEEPALIVE, <<"${keepalive}">>). -define(PH_KEEPALIVE, ?PH("keepalive")).
%% MQTT clean_start %% MQTT clean_start
-define(PH_CLEAR_START, <<"${clean_start}">>). -define(PH_CLEAR_START, ?PH("clean_start")).
%% MQTT Session Expiration time %% MQTT Session Expiration time
-define(PH_EXPIRY_INTERVAL, <<"${expiry_interval}">>). -define(PH_EXPIRY_INTERVAL, ?PH("expiry_interval")).
%% Time when PUBLISH message reaches Broker (ms) %% Time when PUBLISH message reaches Broker (ms)
-define(PH_PUBLISH_RECEIVED_AT, <<"${publish_received_at}">>). -define(PH_PUBLISH_RECEIVED_AT, ?PH("publish_received_at")).
%% Mountpoint for bridging messages %% Mountpoint for bridging messages
-define(PH_MOUNTPOINT, <<"${mountpoint}">>). -define(VAR_MOUNTPOINT, "mountpoint").
-define(PH_MOUNTPOINT, ?PH(?VAR_MOUNTPOINT)).
%% IPAddress and Port of terminal %% IPAddress and Port of terminal
-define(PH_PEERNAME, <<"${peername}">>). -define(PH_PEERNAME, ?PH("peername")).
%% IPAddress and Port listened by emqx %% IPAddress and Port listened by emqx
-define(PH_SOCKNAME, <<"${sockname}">>). -define(PH_SOCKNAME, ?PH("sockname")).
%% whether it is MQTT bridge connection %% whether it is MQTT bridge connection
-define(PH_IS_BRIDGE, <<"${is_bridge}">>). -define(PH_IS_BRIDGE, ?PH("is_bridge")).
%% Terminal connection completion time (s) %% Terminal connection completion time (s)
-define(PH_CONNECTED_AT, <<"${connected_at}">>). -define(PH_CONNECTED_AT, ?PH("connected_at")).
%% Event trigger time(millisecond) %% Event trigger time(millisecond)
-define(PH_TIMESTAMP, <<"${timestamp}">>). -define(PH_TIMESTAMP, ?PH("timestamp")).
%% Terminal disconnection completion time (s) %% Terminal disconnection completion time (s)
-define(PH_DISCONNECTED_AT, <<"${disconnected_at}">>). -define(PH_DISCONNECTED_AT, ?PH("disconnected_at")).
-define(PH_NODE, <<"${node}">>). -define(PH_NODE, ?PH("node")).
-define(PH_REASON, <<"${reason}">>). -define(PH_REASON, ?PH("reason")).
-define(PH_ENDPOINT_NAME, <<"${endpoint_name}">>). -define(PH_ENDPOINT_NAME, ?PH("endpoint_name")).
-define(PH_RETAIN, <<"${retain}">>). -define(VAR_RETAIN, "retain").
-define(PH_RETAIN, ?PH(?VAR_RETAIN)).
%% sync change these place holder with binary def. %% sync change these place holder with binary def.
-define(PH_S_ACTION, "${action}"). -define(PH_S_ACTION, "${action}").

View File

@ -18,6 +18,7 @@
-include_lib("emqx/include/emqx_placeholder.hrl"). -include_lib("emqx/include/emqx_placeholder.hrl").
-include_lib("emqx_authn.hrl"). -include_lib("emqx_authn.hrl").
-include_lib("snabbkaffe/include/trace.hrl").
-export([ -export([
create_resource/3, create_resource/3,
@ -44,13 +45,13 @@
default_headers_no_content_type/0 default_headers_no_content_type/0
]). ]).
-define(AUTHN_PLACEHOLDERS, [ -define(ALLOWED_VARS, [
?PH_USERNAME, ?VAR_USERNAME,
?PH_CLIENTID, ?VAR_CLIENTID,
?PH_PASSWORD, ?VAR_PASSWORD,
?PH_PEERHOST, ?VAR_PEERHOST,
?PH_CERT_SUBJECT, ?VAR_CERT_SUBJECT,
?PH_CERT_CN_NAME ?VAR_CERT_CN_NAME
]). ]).
-define(DEFAULT_RESOURCE_OPTS, #{ -define(DEFAULT_RESOURCE_OPTS, #{
@ -107,48 +108,96 @@ check_password_from_selected_map(Algorithm, Selected, Password) ->
end. end.
parse_deep(Template) -> parse_deep(Template) ->
emqx_placeholder:preproc_tmpl_deep(Template, #{placeholders => ?AUTHN_PLACEHOLDERS}). Result = emqx_template:parse_deep(Template),
handle_disallowed_placeholders(Result, {deep, Template}).
parse_str(Template) -> parse_str(Template) ->
emqx_placeholder:preproc_tmpl(Template, #{placeholders => ?AUTHN_PLACEHOLDERS}). Result = emqx_template:parse(Template),
handle_disallowed_placeholders(Result, {string, Template}).
parse_sql(Template, ReplaceWith) -> parse_sql(Template, ReplaceWith) ->
emqx_placeholder:preproc_sql( {Statement, Result} = emqx_template_sql:parse_prepstmt(
Template, Template,
#{ #{parameters => ReplaceWith, strip_double_quote => true}
replace_with => ReplaceWith, ),
placeholders => ?AUTHN_PLACEHOLDERS, {Statement, handle_disallowed_placeholders(Result, {string, Template})}.
strip_double_quote => true
} handle_disallowed_placeholders(Template, Source) ->
). case emqx_template:validate(?ALLOWED_VARS, Template) of
ok ->
Template;
{error, Disallowed} ->
?tp(warning, "authn_template_invalid", #{
template => Source,
reason => Disallowed,
allowed => #{placeholders => ?ALLOWED_VARS},
notice =>
"Disallowed placeholders will be rendered as is."
" However, consider using `${$}` escaping for literal `$` where"
" needed to avoid unexpected results."
}),
Result = prerender_disallowed_placeholders(Template),
case Source of
{string, _} ->
emqx_template:parse(Result);
{deep, _} ->
emqx_template:parse_deep(Result)
end
end.
prerender_disallowed_placeholders(Template) ->
{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_VARS) of
true -> "${" ++ Name ++ "}";
false -> "${$}{" ++ Name ++ "}"
end
end
}),
Result.
render_deep(Template, Credential) -> render_deep(Template, Credential) ->
emqx_placeholder:proc_tmpl_deep( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{Term, _Errors} = emqx_template:render(
Template, Template,
mapping_credential(Credential), mapping_credential(Credential),
#{return => full_binary, var_trans => fun handle_var/2} #{var_trans => fun to_string/2}
). ),
Term.
render_str(Template, Credential) -> render_str(Template, Credential) ->
emqx_placeholder:proc_tmpl( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{String, _Errors} = emqx_template:render(
Template, Template,
mapping_credential(Credential), mapping_credential(Credential),
#{return => full_binary, var_trans => fun handle_var/2} #{var_trans => fun to_string/2}
). ),
unicode:characters_to_binary(String).
render_urlencoded_str(Template, Credential) -> render_urlencoded_str(Template, Credential) ->
emqx_placeholder:proc_tmpl( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{String, _Errors} = emqx_template:render(
Template, Template,
mapping_credential(Credential), mapping_credential(Credential),
#{return => full_binary, var_trans => fun urlencode_var/2} #{var_trans => fun to_urlencoded_string/2}
). ),
unicode:characters_to_binary(String).
render_sql_params(ParamList, Credential) -> render_sql_params(ParamList, Credential) ->
emqx_placeholder:proc_tmpl( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{Row, _Errors} = emqx_template:render(
ParamList, ParamList,
mapping_credential(Credential), mapping_credential(Credential),
#{return => rawlist, var_trans => fun handle_sql_var/2} #{var_trans => fun to_sql_valaue/2}
). ),
Row.
is_superuser(#{<<"is_superuser">> := Value}) -> is_superuser(#{<<"is_superuser">> := Value}) ->
#{is_superuser => to_bool(Value)}; #{is_superuser => to_bool(Value)};
@ -269,22 +318,24 @@ without_password(Credential, [Name | Rest]) ->
without_password(Credential, Rest) without_password(Credential, Rest)
end. end.
urlencode_var(Var, Value) -> to_urlencoded_string(Name, Value) ->
emqx_http_lib:uri_encode(handle_var(Var, Value)). emqx_http_lib:uri_encode(to_string(Name, Value)).
handle_var(_Name, undefined) -> to_string(Name, Value) ->
<<>>; emqx_template:to_string(render_var(Name, Value)).
handle_var([<<"peerhost">>], PeerHost) ->
emqx_placeholder:bin(inet:ntoa(PeerHost));
handle_var(_, Value) ->
emqx_placeholder:bin(Value).
handle_sql_var(_Name, undefined) -> to_sql_valaue(Name, Value) ->
emqx_utils_sql:to_sql_value(render_var(Name, Value)).
render_var(_, undefined) ->
% NOTE
% Any allowed but undefined binding will be replaced with empty string, even when
% rendering SQL values.
<<>>; <<>>;
handle_sql_var([<<"peerhost">>], PeerHost) -> render_var(?VAR_PEERHOST, Value) ->
emqx_placeholder:bin(inet:ntoa(PeerHost)); inet:ntoa(Value);
handle_sql_var(_, Value) -> render_var(_Name, Value) ->
emqx_placeholder:sql_data(Value). Value.
mapping_credential(C = #{cn := CN, dn := DN}) -> mapping_credential(C = #{cn := CN, dn := DN}) ->
C#{cert_common_name => CN, cert_subject => DN}; C#{cert_common_name => CN, cert_subject => DN};

View File

@ -183,19 +183,14 @@ 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) ->
TopicBin = bin(Topic), Template = emqx_authz_utils:parse_str(Topic, [?VAR_USERNAME, ?VAR_CLIENTID]),
case case emqx_template:is_const(Template) of
emqx_placeholder:preproc_tmpl( true -> emqx_topic:words(bin(Topic));
TopicBin, false -> {pattern, Template}
#{placeholders => [?PH_USERNAME, ?PH_CLIENTID]}
)
of
[{str, _}] -> emqx_topic:words(TopicBin);
Tokens -> {pattern, Tokens}
end. end.
bin(L) when is_list(L) -> bin(L) when is_list(L) ->
list_to_binary(L); unicode:characters_to_binary(L);
bin(B) when is_binary(B) -> bin(B) when is_binary(B) ->
B. B.
@ -307,7 +302,7 @@ match_who(_, _) ->
match_topics(_ClientInfo, _Topic, []) -> match_topics(_ClientInfo, _Topic, []) ->
false; false;
match_topics(ClientInfo, Topic, [{pattern, PatternFilter} | Filters]) -> match_topics(ClientInfo, Topic, [{pattern, PatternFilter} | Filters]) ->
TopicFilter = emqx_placeholder:proc_tmpl(PatternFilter, ClientInfo), TopicFilter = bin(emqx_template:render_strict(PatternFilter, ClientInfo)),
match_topic(emqx_topic:words(Topic), emqx_topic:words(TopicFilter)) orelse match_topic(emqx_topic:words(Topic), emqx_topic:words(TopicFilter)) orelse
match_topics(ClientInfo, Topic, Filters); match_topics(ClientInfo, Topic, Filters);
match_topics(ClientInfo, Topic, [TopicFilter | Filters]) -> match_topics(ClientInfo, Topic, [TopicFilter | Filters]) ->

View File

@ -16,7 +16,9 @@
-module(emqx_authz_utils). -module(emqx_authz_utils).
-include_lib("emqx/include/emqx_placeholder.hrl").
-include_lib("emqx_authz.hrl"). -include_lib("emqx_authz.hrl").
-include_lib("snabbkaffe/include/trace.hrl").
-export([ -export([
cleanup_resources/0, cleanup_resources/0,
@ -108,48 +110,97 @@ update_config(Path, ConfigRequest) ->
}). }).
parse_deep(Template, PlaceHolders) -> parse_deep(Template, PlaceHolders) ->
emqx_placeholder:preproc_tmpl_deep(Template, #{placeholders => PlaceHolders}). Result = emqx_template:parse_deep(Template),
handle_disallowed_placeholders(Result, {deep, Template}, PlaceHolders).
parse_str(Template, PlaceHolders) -> parse_str(Template, PlaceHolders) ->
emqx_placeholder:preproc_tmpl(Template, #{placeholders => PlaceHolders}). Result = emqx_template:parse(Template),
handle_disallowed_placeholders(Result, {string, Template}, PlaceHolders).
parse_sql(Template, ReplaceWith, PlaceHolders) -> parse_sql(Template, ReplaceWith, PlaceHolders) ->
emqx_placeholder:preproc_sql( {Statement, Result} = emqx_template_sql:parse_prepstmt(
Template, Template,
#{ #{parameters => ReplaceWith, strip_double_quote => true}
replace_with => ReplaceWith, ),
placeholders => PlaceHolders, FResult = handle_disallowed_placeholders(Result, {string, Template}, PlaceHolders),
strip_double_quote => true {Statement, FResult}.
}
). handle_disallowed_placeholders(Template, Source, Allowed) ->
case emqx_template:validate(Allowed, Template) of
ok ->
Template;
{error, Disallowed} ->
?tp(warning, "authz_template_invalid", #{
template => Source,
reason => Disallowed,
allowed => #{placeholders => Allowed},
notice =>
"Disallowed placeholders will be rendered as is."
" However, consider using `${$}` escaping for literal `$` where"
" needed to avoid unexpected results."
}),
Result = prerender_disallowed_placeholders(Template, Allowed),
case Source of
{string, _} ->
emqx_template:parse(Result);
{deep, _} ->
emqx_template:parse_deep(Result)
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) ->
emqx_placeholder:proc_tmpl_deep( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{Term, _Errors} = emqx_template:render(
Template, Template,
client_vars(Values), client_vars(Values),
#{return => full_binary, var_trans => fun handle_var/2} #{var_trans => fun to_string/2}
). ),
Term.
render_str(Template, Values) -> render_str(Template, Values) ->
emqx_placeholder:proc_tmpl( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{String, _Errors} = emqx_template:render(
Template, Template,
client_vars(Values), client_vars(Values),
#{return => full_binary, var_trans => fun handle_var/2} #{var_trans => fun to_string/2}
). ),
unicode:characters_to_binary(String).
render_urlencoded_str(Template, Values) -> render_urlencoded_str(Template, Values) ->
emqx_placeholder:proc_tmpl( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{String, _Errors} = emqx_template:render(
Template, Template,
client_vars(Values), client_vars(Values),
#{return => full_binary, var_trans => fun urlencode_var/2} #{var_trans => fun to_urlencoded_string/2}
). ),
unicode:characters_to_binary(String).
render_sql_params(ParamList, Values) -> render_sql_params(ParamList, Values) ->
emqx_placeholder:proc_tmpl( % NOTE
% Ignoring errors here, undefined bindings will be replaced with empty string.
{Row, _Errors} = emqx_template:render(
ParamList, ParamList,
client_vars(Values), client_vars(Values),
#{return => rawlist, var_trans => fun handle_sql_var/2} #{var_trans => fun to_sql_value/2}
). ),
Row.
-spec parse_http_resp_body(binary(), binary()) -> allow | deny | ignore | error. -spec parse_http_resp_body(binary(), binary()) -> allow | deny | ignore | error.
parse_http_resp_body(<<"application/x-www-form-urlencoded", _/binary>>, Body) -> parse_http_resp_body(<<"application/x-www-form-urlencoded", _/binary>>, Body) ->
@ -215,22 +266,24 @@ convert_client_var({dn, DN}) -> {cert_subject, DN};
convert_client_var({protocol, Proto}) -> {proto_name, Proto}; convert_client_var({protocol, Proto}) -> {proto_name, Proto};
convert_client_var(Other) -> Other. convert_client_var(Other) -> Other.
urlencode_var(Var, Value) -> to_urlencoded_string(Name, Value) ->
emqx_http_lib:uri_encode(handle_var(Var, Value)). emqx_http_lib:uri_encode(to_string(Name, Value)).
handle_var(_Name, undefined) -> to_string(Name, Value) ->
<<>>; emqx_template:to_string(render_var(Name, Value)).
handle_var([<<"peerhost">>], IpAddr) ->
inet_parse:ntoa(IpAddr);
handle_var(_Name, Value) ->
emqx_placeholder:bin(Value).
handle_sql_var(_Name, undefined) -> to_sql_value(Name, Value) ->
emqx_utils_sql:to_sql_value(render_var(Name, Value)).
render_var(_, undefined) ->
% NOTE
% Any allowed but undefined binding will be replaced with empty string, even when
% rendering SQL values.
<<>>; <<>>;
handle_sql_var([<<"peerhost">>], IpAddr) -> render_var(?VAR_PEERHOST, Value) ->
inet_parse:ntoa(IpAddr); inet:ntoa(Value);
handle_sql_var(_Name, Value) -> render_var(_Name, Value) ->
emqx_placeholder:sql_data(Value). Value.
bin(A) when is_atom(A) -> atom_to_binary(A, utf8); bin(A) when is_atom(A) -> atom_to_binary(A, utf8);
bin(L) when is_list(L) -> list_to_binary(L); bin(L) when is_list(L) -> list_to_binary(L);

View File

@ -67,6 +67,10 @@ set_special_configs(_App) ->
ok. ok.
t_compile(_) -> t_compile(_) ->
% NOTE
% Some of the following testcase are relying on the internal representation of
% `emqx_template:t()`. If the internal representation is changed, these testcases
% may fail.
?assertEqual({deny, all, all, [['#']]}, emqx_authz_rule:compile({deny, all})), ?assertEqual({deny, all, all, [['#']]}, emqx_authz_rule:compile({deny, all})),
?assertEqual( ?assertEqual(
@ -74,13 +78,13 @@ t_compile(_) ->
emqx_authz_rule:compile({allow, {ipaddr, "127.0.0.1"}, all, [{eq, "#"}, {eq, "+"}]}) emqx_authz_rule:compile({allow, {ipaddr, "127.0.0.1"}, all, [{eq, "#"}, {eq, "+"}]})
), ),
?assertEqual( ?assertMatch(
{allow, {allow,
{ipaddrs, [ {ipaddrs, [
{{127, 0, 0, 1}, {127, 0, 0, 1}, 32}, {{127, 0, 0, 1}, {127, 0, 0, 1}, 32},
{{192, 168, 1, 0}, {192, 168, 1, 255}, 24} {{192, 168, 1, 0}, {192, 168, 1, 255}, 24}
]}, ]},
subscribe, [{pattern, [{var, [<<"clientid">>]}]}]}, subscribe, [{pattern, [{var, "clientid", [_]}]}]},
emqx_authz_rule:compile( emqx_authz_rule:compile(
{allow, {ipaddrs, ["127.0.0.1", "192.168.1.0/24"]}, subscribe, [?PH_S_CLIENTID]} {allow, {ipaddrs, ["127.0.0.1", "192.168.1.0/24"]}, subscribe, [?PH_S_CLIENTID]}
) )
@ -102,7 +106,7 @@ t_compile(_) ->
{clientid, {re_pattern, _, _, _, _}} {clientid, {re_pattern, _, _, _, _}}
]}, ]},
publish, [ publish, [
{pattern, [{var, [<<"username">>]}]}, {pattern, [{var, [<<"clientid">>]}]} {pattern, [{var, "username", [_]}]}, {pattern, [{var, "clientid", [_]}]}
]}, ]},
emqx_authz_rule:compile( emqx_authz_rule:compile(
{allow, {allow,
@ -114,9 +118,9 @@ t_compile(_) ->
) )
), ),
?assertEqual( ?assertMatch(
{allow, {username, {eq, <<"test">>}}, publish, [ {allow, {username, {eq, <<"test">>}}, publish, [
{pattern, [{str, <<"t/foo">>}, {var, [<<"username">>]}, {str, <<"boo">>}]} {pattern, [<<"t/foo">>, {var, "username", [_]}, <<"boo">>]}
]}, ]},
emqx_authz_rule:compile({allow, {username, "test"}, publish, ["t/foo${username}boo"]}) emqx_authz_rule:compile({allow, {username, "test"}, publish, ["t/foo${username}boo"]})
), ),

View File

@ -38,21 +38,21 @@
-compile(nowarn_export_all). -compile(nowarn_export_all).
-endif. -endif.
-define(PLACEHOLDERS, [ -define(ALLOWED_VARS, [
?PH_USERNAME, ?VAR_USERNAME,
?PH_CLIENTID, ?VAR_CLIENTID,
?PH_PEERHOST, ?VAR_PEERHOST,
?PH_PROTONAME, ?VAR_PROTONAME,
?PH_MOUNTPOINT, ?VAR_MOUNTPOINT,
?PH_TOPIC, ?VAR_TOPIC,
?PH_ACTION, ?VAR_ACTION,
?PH_CERT_SUBJECT, ?VAR_CERT_SUBJECT,
?PH_CERT_CN_NAME ?VAR_CERT_CN_NAME
]). ]).
-define(PLACEHOLDERS_FOR_RICH_ACTIONS, [ -define(ALLOWED_VARS_RICH_ACTIONS, [
?PH_QOS, ?VAR_QOS,
?PH_RETAIN ?VAR_RETAIN
]). ]).
description() -> description() ->
@ -157,14 +157,14 @@ parse_config(
method => Method, method => Method,
base_url => BaseUrl, base_url => BaseUrl,
headers => Headers, headers => Headers,
base_path_templete => emqx_authz_utils:parse_str(Path, placeholders()), base_path_templete => emqx_authz_utils:parse_str(Path, allowed_vars()),
base_query_template => emqx_authz_utils:parse_deep( base_query_template => emqx_authz_utils:parse_deep(
cow_qs:parse_qs(to_bin(Query)), cow_qs:parse_qs(to_bin(Query)),
placeholders() allowed_vars()
), ),
body_template => emqx_authz_utils:parse_deep( body_template => emqx_authz_utils:parse_deep(
maps:to_list(maps:get(body, Conf, #{})), maps:to_list(maps:get(body, Conf, #{})),
placeholders() allowed_vars()
), ),
request_timeout => ReqTimeout, request_timeout => ReqTimeout,
%% pool_type default value `random` %% pool_type default value `random`
@ -260,10 +260,10 @@ to_bin(B) when is_binary(B) -> B;
to_bin(L) when is_list(L) -> list_to_binary(L); to_bin(L) when is_list(L) -> list_to_binary(L);
to_bin(X) -> X. to_bin(X) -> X.
placeholders() -> allowed_vars() ->
placeholders(emqx_authz:feature_available(rich_actions)). allowed_vars(emqx_authz:feature_available(rich_actions)).
placeholders(true) -> allowed_vars(true) ->
?PLACEHOLDERS ++ ?PLACEHOLDERS_FOR_RICH_ACTIONS; ?ALLOWED_VARS ++ ?ALLOWED_VARS_RICH_ACTIONS;
placeholders(false) -> allowed_vars(false) ->
?PLACEHOLDERS. ?ALLOWED_VARS.

View File

@ -27,7 +27,7 @@
-define(PATH, [?CONF_NS_ATOM]). -define(PATH, [?CONF_NS_ATOM]).
-define(HTTP_PORT, 32333). -define(HTTP_PORT, 32333).
-define(HTTP_PATH, "/auth"). -define(HTTP_PATH, "/auth/[...]").
-define(CREDENTIALS, #{ -define(CREDENTIALS, #{
clientid => <<"clienta">>, clientid => <<"clienta">>,
username => <<"plain">>, username => <<"plain">>,
@ -146,8 +146,12 @@ t_authenticate(_Config) ->
test_user_auth(#{ test_user_auth(#{
handler := Handler, handler := Handler,
config_params := SpecificConfgParams, config_params := SpecificConfgParams,
result := Result result := Expect
}) -> }) ->
Result = perform_user_auth(SpecificConfgParams, Handler, ?CREDENTIALS),
?assertEqual(Expect, Result).
perform_user_auth(SpecificConfgParams, Handler, Credentials) ->
AuthConfig = maps:merge(raw_http_auth_config(), SpecificConfgParams), AuthConfig = maps:merge(raw_http_auth_config(), SpecificConfgParams),
{ok, _} = emqx:update_config( {ok, _} = emqx:update_config(
@ -157,21 +161,21 @@ test_user_auth(#{
ok = emqx_authn_http_test_server:set_handler(Handler), ok = emqx_authn_http_test_server:set_handler(Handler),
?assertEqual(Result, emqx_access_control:authenticate(?CREDENTIALS)), Result = emqx_access_control:authenticate(Credentials),
emqx_authn_test_lib:delete_authenticators( emqx_authn_test_lib:delete_authenticators(
[authentication], [authentication],
?GLOBAL ?GLOBAL
). ),
Result.
t_authenticate_path_placeholders(_Config) -> t_authenticate_path_placeholders(_Config) ->
ok = emqx_authn_http_test_server:stop(),
{ok, _} = emqx_authn_http_test_server:start_link(?HTTP_PORT, <<"/[...]">>),
ok = emqx_authn_http_test_server:set_handler( ok = emqx_authn_http_test_server:set_handler(
fun(Req0, State) -> fun(Req0, State) ->
Req = Req =
case cowboy_req:path(Req0) of case cowboy_req:path(Req0) of
<<"/my/p%20ath//us%20er/auth//">> -> <<"/auth/p%20ath//us%20er/auth//">> ->
cowboy_req:reply( cowboy_req:reply(
200, 200,
#{<<"content-type">> => <<"application/json">>}, #{<<"content-type">> => <<"application/json">>},
@ -193,7 +197,7 @@ t_authenticate_path_placeholders(_Config) ->
AuthConfig = maps:merge( AuthConfig = maps:merge(
raw_http_auth_config(), raw_http_auth_config(),
#{ #{
<<"url">> => <<"http://127.0.0.1:32333/my/p%20ath//${username}/auth//">>, <<"url">> => <<"http://127.0.0.1:32333/auth/p%20ath//${username}/auth//">>,
<<"body">> => #{} <<"body">> => #{}
} }
), ),
@ -255,6 +259,39 @@ t_no_value_for_placeholder(_Config) ->
?GLOBAL ?GLOBAL
). ).
t_disallowed_placeholders_preserved(_Config) ->
Config = #{
<<"method">> => <<"post">>,
<<"headers">> => #{<<"content-type">> => <<"application/json">>},
<<"body">> => #{
<<"username">> => ?PH_USERNAME,
<<"password">> => ?PH_PASSWORD,
<<"this">> => <<"${whatisthis}">>
}
},
Handler = fun(Req0, State) ->
{ok, Body, Req1} = cowboy_req:read_body(Req0),
#{
<<"username">> := <<"plain">>,
<<"password">> := <<"plain">>,
<<"this">> := <<"${whatisthis}">>
} = emqx_utils_json:decode(Body),
Req = cowboy_req:reply(
200,
#{<<"content-type">> => <<"application/json">>},
emqx_utils_json:encode(#{result => allow, is_superuser => false}),
Req1
),
{ok, Req, State}
end,
?assertMatch({ok, _}, perform_user_auth(Config, Handler, ?CREDENTIALS)),
% NOTE: disallowed placeholder left intact, which makes the URL invalid
ConfigUrl = Config#{
<<"url">> => <<"http://127.0.0.1:32333/auth/${whatisthis}">>
},
?assertMatch({error, _}, perform_user_auth(ConfigUrl, Handler, ?CREDENTIALS)).
t_destroy(_Config) -> t_destroy(_Config) ->
AuthConfig = raw_http_auth_config(), AuthConfig = raw_http_auth_config(),

View File

@ -494,6 +494,67 @@ t_no_value_for_placeholder(_Config) ->
emqx_access_control:authorize(ClientInfo, ?AUTHZ_PUBLISH, <<"t">>) emqx_access_control:authorize(ClientInfo, ?AUTHZ_PUBLISH, <<"t">>)
). ).
t_disallowed_placeholders_preserved(_Config) ->
ok = setup_handler_and_config(
fun(Req0, State) ->
{ok, Body, Req1} = cowboy_req:read_body(Req0),
?assertMatch(
#{
<<"cname">> := <<>>,
<<"usertypo">> := <<"${usertypo}">>
},
emqx_utils_json:decode(Body)
),
{ok, ?AUTHZ_HTTP_RESP(allow, Req1), State}
end,
#{
<<"method">> => <<"post">>,
<<"body">> => #{
<<"cname">> => ?PH_CERT_CN_NAME,
<<"usertypo">> => <<"${usertypo}">>
}
}
),
ClientInfo = #{
clientid => <<"client id">>,
username => <<"user name">>,
peerhost => {127, 0, 0, 1},
protocol => <<"MQTT">>,
zone => default,
listener => {tcp, default}
},
?assertEqual(
allow,
emqx_access_control:authorize(ClientInfo, ?AUTHZ_PUBLISH, <<"t">>)
).
t_disallowed_placeholders_path(_Config) ->
ok = setup_handler_and_config(
fun(Req, State) ->
{ok, ?AUTHZ_HTTP_RESP(allow, Req), State}
end,
#{
<<"url">> => <<"http://127.0.0.1:33333/authz/use%20rs/${typo}">>
}
),
ClientInfo = #{
clientid => <<"client id">>,
username => <<"user name">>,
peerhost => {127, 0, 0, 1},
protocol => <<"MQTT">>,
zone => default,
listener => {tcp, default}
},
% % NOTE: disallowed placeholder left intact, which makes the URL invalid
?assertEqual(
deny,
emqx_access_control:authorize(ClientInfo, ?AUTHZ_PUBLISH, <<"t">>)
).
t_create_replace(_Config) -> t_create_replace(_Config) ->
ClientInfo = #{ ClientInfo = #{
clientid => <<"clientid">>, clientid => <<"clientid">>,

View File

@ -35,12 +35,12 @@
-compile(nowarn_export_all). -compile(nowarn_export_all).
-endif. -endif.
-define(PLACEHOLDERS, [ -define(ALLOWED_VARS, [
?PH_USERNAME, ?VAR_USERNAME,
?PH_CLIENTID, ?VAR_CLIENTID,
?PH_PEERHOST, ?VAR_PEERHOST,
?PH_CERT_CN_NAME, ?VAR_CERT_CN_NAME,
?PH_CERT_SUBJECT ?VAR_CERT_SUBJECT
]). ]).
description() -> description() ->
@ -49,11 +49,11 @@ description() ->
create(#{filter := Filter} = Source) -> create(#{filter := Filter} = Source) ->
ResourceId = emqx_authz_utils:make_resource_id(?MODULE), ResourceId = emqx_authz_utils:make_resource_id(?MODULE),
{ok, _Data} = emqx_authz_utils:create_resource(ResourceId, emqx_mongodb, Source), {ok, _Data} = emqx_authz_utils:create_resource(ResourceId, emqx_mongodb, Source),
FilterTemp = emqx_authz_utils:parse_deep(Filter, ?PLACEHOLDERS), FilterTemp = emqx_authz_utils:parse_deep(Filter, ?ALLOWED_VARS),
Source#{annotations => #{id => ResourceId}, filter_template => FilterTemp}. Source#{annotations => #{id => ResourceId}, filter_template => FilterTemp}.
update(#{filter := Filter} = Source) -> update(#{filter := Filter} = Source) ->
FilterTemp = emqx_authz_utils:parse_deep(Filter, ?PLACEHOLDERS), FilterTemp = emqx_authz_utils:parse_deep(Filter, ?ALLOWED_VARS),
case emqx_authz_utils:update_resource(emqx_mongodb, Source) of case emqx_authz_utils:update_resource(emqx_mongodb, Source) of
{error, Reason} -> {error, Reason} ->
error({load_config_error, Reason}); error({load_config_error, Reason});

View File

@ -37,26 +37,26 @@
-compile(nowarn_export_all). -compile(nowarn_export_all).
-endif. -endif.
-define(PLACEHOLDERS, [ -define(ALLOWED_VARS, [
?PH_USERNAME, ?VAR_USERNAME,
?PH_CLIENTID, ?VAR_CLIENTID,
?PH_PEERHOST, ?VAR_PEERHOST,
?PH_CERT_CN_NAME, ?VAR_CERT_CN_NAME,
?PH_CERT_SUBJECT ?VAR_CERT_SUBJECT
]). ]).
description() -> description() ->
"AuthZ with Mysql". "AuthZ with Mysql".
create(#{query := SQL} = Source0) -> create(#{query := SQL} = Source0) ->
{PrepareSQL, TmplToken} = emqx_authz_utils:parse_sql(SQL, '?', ?PLACEHOLDERS), {PrepareSQL, TmplToken} = emqx_authz_utils:parse_sql(SQL, '?', ?ALLOWED_VARS),
ResourceId = emqx_authz_utils:make_resource_id(?MODULE), ResourceId = emqx_authz_utils:make_resource_id(?MODULE),
Source = Source0#{prepare_statement => #{?PREPARE_KEY => PrepareSQL}}, Source = Source0#{prepare_statement => #{?PREPARE_KEY => PrepareSQL}},
{ok, _Data} = emqx_authz_utils:create_resource(ResourceId, emqx_mysql, Source), {ok, _Data} = emqx_authz_utils:create_resource(ResourceId, emqx_mysql, Source),
Source#{annotations => #{id => ResourceId, tmpl_token => TmplToken}}. Source#{annotations => #{id => ResourceId, tmpl_token => TmplToken}}.
update(#{query := SQL} = Source0) -> update(#{query := SQL} = Source0) ->
{PrepareSQL, TmplToken} = emqx_authz_utils:parse_sql(SQL, '?', ?PLACEHOLDERS), {PrepareSQL, TmplToken} = emqx_authz_utils:parse_sql(SQL, '?', ?ALLOWED_VARS),
Source = Source0#{prepare_statement => #{?PREPARE_KEY => PrepareSQL}}, Source = Source0#{prepare_statement => #{?PREPARE_KEY => PrepareSQL}},
case emqx_authz_utils:update_resource(emqx_mysql, Source) of case emqx_authz_utils:update_resource(emqx_mysql, Source) of
{error, Reason} -> {error, Reason} ->

View File

@ -37,19 +37,19 @@
-compile(nowarn_export_all). -compile(nowarn_export_all).
-endif. -endif.
-define(PLACEHOLDERS, [ -define(ALLOWED_VARS, [
?PH_USERNAME, ?VAR_USERNAME,
?PH_CLIENTID, ?VAR_CLIENTID,
?PH_PEERHOST, ?VAR_PEERHOST,
?PH_CERT_CN_NAME, ?VAR_CERT_CN_NAME,
?PH_CERT_SUBJECT ?VAR_CERT_SUBJECT
]). ]).
description() -> description() ->
"AuthZ with PostgreSQL". "AuthZ with PostgreSQL".
create(#{query := SQL0} = Source) -> create(#{query := SQL0} = Source) ->
{SQL, PlaceHolders} = emqx_authz_utils:parse_sql(SQL0, '$n', ?PLACEHOLDERS), {SQL, PlaceHolders} = emqx_authz_utils:parse_sql(SQL0, '$n', ?ALLOWED_VARS),
ResourceID = emqx_authz_utils:make_resource_id(emqx_postgresql), ResourceID = emqx_authz_utils:make_resource_id(emqx_postgresql),
{ok, _Data} = emqx_authz_utils:create_resource( {ok, _Data} = emqx_authz_utils:create_resource(
ResourceID, ResourceID,
@ -59,7 +59,7 @@ create(#{query := SQL0} = Source) ->
Source#{annotations => #{id => ResourceID, placeholders => PlaceHolders}}. Source#{annotations => #{id => ResourceID, placeholders => PlaceHolders}}.
update(#{query := SQL0, annotations := #{id := ResourceID}} = Source) -> update(#{query := SQL0, annotations := #{id := ResourceID}} = Source) ->
{SQL, PlaceHolders} = emqx_authz_utils:parse_sql(SQL0, '$n', ?PLACEHOLDERS), {SQL, PlaceHolders} = emqx_authz_utils:parse_sql(SQL0, '$n', ?ALLOWED_VARS),
case case
emqx_authz_utils:update_resource( emqx_authz_utils:update_resource(
emqx_postgresql, emqx_postgresql,

View File

@ -35,12 +35,12 @@
-compile(nowarn_export_all). -compile(nowarn_export_all).
-endif. -endif.
-define(PLACEHOLDERS, [ -define(ALLOWED_VARS, [
?PH_CERT_CN_NAME, ?VAR_CERT_CN_NAME,
?PH_CERT_SUBJECT, ?VAR_CERT_SUBJECT,
?PH_PEERHOST, ?VAR_PEERHOST,
?PH_CLIENTID, ?VAR_CLIENTID,
?PH_USERNAME ?VAR_USERNAME
]). ]).
description() -> description() ->
@ -133,7 +133,7 @@ parse_cmd(Query) ->
case emqx_redis_command:split(Query) of case emqx_redis_command:split(Query) of
{ok, Cmd} -> {ok, Cmd} ->
ok = validate_cmd(Cmd), ok = validate_cmd(Cmd),
emqx_authz_utils:parse_deep(Cmd, ?PLACEHOLDERS); emqx_authz_utils:parse_deep(Cmd, ?ALLOWED_VARS);
{error, Reason} -> {error, Reason} ->
error({invalid_redis_cmd, Reason, Query}) error({invalid_redis_cmd, Reason, Query})
end. end.

View File

@ -479,61 +479,47 @@ preprocess_request(
} = Req } = Req
) -> ) ->
#{ #{
method => emqx_placeholder:preproc_tmpl(to_bin(Method)), method => parse_template(to_bin(Method)),
path => emqx_placeholder:preproc_tmpl(Path), path => parse_template(Path),
body => maybe_preproc_tmpl(body, Req), body => maybe_parse_template(body, Req),
headers => wrap_auth_header(preproc_headers(Headers)), headers => parse_headers(Headers),
request_timeout => maps:get(request_timeout, Req, ?DEFAULT_REQUEST_TIMEOUT_MS), request_timeout => maps:get(request_timeout, Req, ?DEFAULT_REQUEST_TIMEOUT_MS),
max_retries => maps:get(max_retries, Req, 2) max_retries => maps:get(max_retries, Req, 2)
}. }.
preproc_headers(Headers) when is_map(Headers) -> parse_headers(Headers) when is_map(Headers) ->
maps:fold( maps:fold(
fun(K, V, Acc) -> fun(K, V, Acc) -> [parse_header(K, V) | Acc] end,
[
{
emqx_placeholder:preproc_tmpl(to_bin(K)),
emqx_placeholder:preproc_tmpl(to_bin(V))
}
| Acc
]
end,
[], [],
Headers Headers
); );
preproc_headers(Headers) when is_list(Headers) -> parse_headers(Headers) when is_list(Headers) ->
lists:map( lists:map(
fun({K, V}) -> fun({K, V}) -> parse_header(K, V) end,
{
emqx_placeholder:preproc_tmpl(to_bin(K)),
emqx_placeholder:preproc_tmpl(to_bin(V))
}
end,
Headers Headers
). ).
wrap_auth_header(Headers) -> parse_header(K, V) ->
lists:map(fun maybe_wrap_auth_header/1, Headers). KStr = to_bin(K),
VTpl = parse_template(to_bin(V)),
{parse_template(KStr), maybe_wrap_auth_header(KStr, VTpl)}.
maybe_wrap_auth_header({[{str, Key}] = StrKey, Val}) -> maybe_wrap_auth_header(Key, VTpl) when
{_, MaybeWrapped} = maybe_wrap_auth_header({Key, Val}), (byte_size(Key) =:= 19 orelse byte_size(Key) =:= 13)
{StrKey, MaybeWrapped};
maybe_wrap_auth_header({Key, Val} = Header) when
is_binary(Key), (size(Key) =:= 19 orelse size(Key) =:= 13)
-> ->
%% We check the size of potential keys in the guard above and consider only %% We check the size of potential keys in the guard above and consider only
%% those that match the number of characters of either "Authorization" or %% those that match the number of characters of either "Authorization" or
%% "Proxy-Authorization". %% "Proxy-Authorization".
case try_bin_to_lower(Key) of case try_bin_to_lower(Key) of
<<"authorization">> -> <<"authorization">> ->
{Key, emqx_secret:wrap(Val)}; emqx_secret:wrap(VTpl);
<<"proxy-authorization">> -> <<"proxy-authorization">> ->
{Key, emqx_secret:wrap(Val)}; emqx_secret:wrap(VTpl);
_Other -> _Other ->
Header VTpl
end; end;
maybe_wrap_auth_header(Header) -> maybe_wrap_auth_header(_Key, VTpl) ->
Header. VTpl.
try_bin_to_lower(Bin) -> try_bin_to_lower(Bin) ->
try iolist_to_binary(string:lowercase(Bin)) of try iolist_to_binary(string:lowercase(Bin)) of
@ -542,46 +528,57 @@ try_bin_to_lower(Bin) ->
_:_ -> Bin _:_ -> Bin
end. end.
maybe_preproc_tmpl(Key, Conf) -> maybe_parse_template(Key, Conf) ->
case maps:get(Key, Conf, undefined) of case maps:get(Key, Conf, undefined) of
undefined -> undefined; undefined -> undefined;
Val -> emqx_placeholder:preproc_tmpl(Val) Val -> parse_template(Val)
end. end.
parse_template(String) ->
emqx_template:parse(String).
process_request( process_request(
#{ #{
method := MethodTks, method := MethodTemplate,
path := PathTks, path := PathTemplate,
body := BodyTks, body := BodyTemplate,
headers := HeadersTks, headers := HeadersTemplate,
request_timeout := ReqTimeout request_timeout := ReqTimeout
} = Conf, } = Conf,
Msg Msg
) -> ) ->
Conf#{ Conf#{
method => make_method(emqx_placeholder:proc_tmpl(MethodTks, Msg)), method => make_method(render_template_string(MethodTemplate, Msg)),
path => emqx_placeholder:proc_tmpl(PathTks, Msg), path => unicode:characters_to_list(render_template(PathTemplate, Msg)),
body => process_request_body(BodyTks, Msg), body => render_request_body(BodyTemplate, Msg),
headers => proc_headers(HeadersTks, Msg), headers => render_headers(HeadersTemplate, Msg),
request_timeout => ReqTimeout request_timeout => ReqTimeout
}. }.
process_request_body(undefined, Msg) -> render_request_body(undefined, Msg) ->
emqx_utils_json:encode(Msg); emqx_utils_json:encode(Msg);
process_request_body(BodyTks, Msg) -> render_request_body(BodyTks, Msg) ->
emqx_placeholder:proc_tmpl(BodyTks, Msg). render_template(BodyTks, Msg).
proc_headers(HeaderTks, Msg) -> render_headers(HeaderTks, Msg) ->
lists:map( lists:map(
fun({K, V}) -> fun({K, V}) ->
{ {
emqx_placeholder:proc_tmpl(K, Msg), render_template_string(K, Msg),
emqx_placeholder:proc_tmpl(emqx_secret:unwrap(V), Msg) render_template_string(emqx_secret:unwrap(V), Msg)
} }
end, end,
HeaderTks HeaderTks
). ).
render_template(Template, Msg) ->
% NOTE: ignoring errors here, missing variables will be rendered as `"undefined"`.
{String, _Errors} = emqx_template:render(Template, {emqx_jsonish, Msg}),
String.
render_template_string(Template, Msg) ->
unicode:characters_to_binary(render_template(Template, Msg)).
make_method(M) when M == <<"POST">>; M == <<"post">> -> post; make_method(M) when M == <<"POST">>; M == <<"post">> -> post;
make_method(M) when M == <<"PUT">>; M == <<"put">> -> put; make_method(M) when M == <<"PUT">>; M == <<"put">> -> put;
make_method(M) when M == <<"GET">>; M == <<"get">> -> get; make_method(M) when M == <<"GET">>; M == <<"get">> -> get;
@ -716,8 +713,6 @@ maybe_retry(Result, _Context, ReplyFunAndArgs) ->
emqx_resource:apply_reply_fun(ReplyFunAndArgs, Result). emqx_resource:apply_reply_fun(ReplyFunAndArgs, Result).
%% The HOCON schema system may generate sensitive keys with this format %% The HOCON schema system may generate sensitive keys with this format
is_sensitive_key([{str, StringKey}]) ->
is_sensitive_key(StringKey);
is_sensitive_key(Atom) when is_atom(Atom) -> is_sensitive_key(Atom) when is_atom(Atom) ->
is_sensitive_key(erlang:atom_to_binary(Atom)); is_sensitive_key(erlang:atom_to_binary(Atom));
is_sensitive_key(Bin) when is_binary(Bin), (size(Bin) =:= 19 orelse size(Bin) =:= 13) -> is_sensitive_key(Bin) when is_binary(Bin), (size(Bin) =:= 19 orelse size(Bin) =:= 13) ->
@ -742,24 +737,18 @@ redact(Data) ->
%% and we also can't know the body format and where the sensitive data will be %% and we also can't know the body format and where the sensitive data will be
%% so the easy way to keep data security is redacted the whole body %% so the easy way to keep data security is redacted the whole body
redact_request({Path, Headers}) -> redact_request({Path, Headers}) ->
{Path, redact(Headers)}; {Path, Headers};
redact_request({Path, Headers, _Body}) -> redact_request({Path, Headers, _Body}) ->
{Path, redact(Headers), <<"******">>}. {Path, Headers, <<"******">>}.
-ifdef(TEST). -ifdef(TEST).
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
redact_test_() -> redact_test_() ->
TestData1 = [ TestData = #{
headers => [
{<<"content-type">>, <<"application/json">>}, {<<"content-type">>, <<"application/json">>},
{<<"Authorization">>, <<"Basic YWxhZGRpbjpvcGVuc2VzYW1l">>} {<<"Authorization">>, <<"Basic YWxhZGRpbjpvcGVuc2VzYW1l">>}
],
TestData2 = #{
headers =>
[
{[{str, <<"content-type">>}], [{str, <<"application/json">>}]},
{[{str, <<"Authorization">>}], [{str, <<"Basic YWxhZGRpbjpvcGVuc2VzYW1l">>}]}
] ]
}, },
[ [
@ -770,8 +759,7 @@ redact_test_() ->
?_assert(is_sensitive_key('PrOxy-authoRizaTion')), ?_assert(is_sensitive_key('PrOxy-authoRizaTion')),
?_assertNot(is_sensitive_key(<<"Something">>)), ?_assertNot(is_sensitive_key(<<"Something">>)),
?_assertNot(is_sensitive_key(89)), ?_assertNot(is_sensitive_key(89)),
?_assertNotEqual(TestData1, redact(TestData1)), ?_assertNotEqual(TestData, redact(TestData))
?_assertNotEqual(TestData2, redact(TestData2))
]. ].
join_paths_test_() -> join_paths_test_() ->

View File

@ -83,7 +83,8 @@ is_wrapped(Secret) when is_function(Secret) ->
is_wrapped(_Other) -> is_wrapped(_Other) ->
false. false.
untmpl([{_, V} | _]) -> V. untmpl(Tpl) ->
iolist_to_binary(emqx_template:render_strict(Tpl, #{})).
is_unwrapped_headers(Headers) -> is_unwrapped_headers(Headers) ->
lists:all(fun is_unwrapped_header/1, Headers). lists:all(fun is_unwrapped_header/1, Headers).

View File

@ -566,7 +566,6 @@ t_simple_sql_query(Config) ->
t_missing_data(Config) -> t_missing_data(Config) ->
BatchSize = ?config(batch_size, Config), BatchSize = ?config(batch_size, Config),
IsBatch = BatchSize > 1,
?assertMatch( ?assertMatch(
{ok, _}, {ok, _},
create_bridge(Config) create_bridge(Config)
@ -577,8 +576,8 @@ t_missing_data(Config) ->
), ),
send_message(Config, #{}), send_message(Config, #{}),
{ok, [Event]} = snabbkaffe:receive_events(SRef), {ok, [Event]} = snabbkaffe:receive_events(SRef),
case IsBatch of case BatchSize of
true -> N when N > 1 ->
?assertMatch( ?assertMatch(
#{ #{
result := result :=
@ -588,7 +587,7 @@ t_missing_data(Config) ->
}, },
Event Event
); );
false -> 1 ->
?assertMatch( ?assertMatch(
#{ #{
result := result :=

View File

@ -324,6 +324,7 @@ connect_and_drop_table(Config) ->
connect_and_clear_table(Config) -> connect_and_clear_table(Config) ->
Con = connect_direct_pgsql(Config), Con = connect_direct_pgsql(Config),
_ = epgsql:squery(Con, ?SQL_CREATE_TABLE),
{ok, _} = epgsql:squery(Con, ?SQL_DELETE), {ok, _} = epgsql:squery(Con, ?SQL_DELETE),
ok = epgsql:close(Con). ok = epgsql:close(Con).
@ -668,7 +669,7 @@ t_missing_table(Config) ->
ok ok
end, end,
fun(Trace) -> fun(Trace) ->
?assertMatch([_, _, _], ?of_kind(pgsql_undefined_table, Trace)), ?assertMatch([_], ?of_kind(pgsql_undefined_table, Trace)),
ok ok
end end
), ),

View File

@ -1,35 +0,0 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_connector_utils).
-export([split_insert_sql/1]).
%% SQL = <<"INSERT INTO \"abc\" (c1,c2,c3) VALUES (${1}, ${1}, ${1})">>
split_insert_sql(SQL) ->
case re:split(SQL, "((?i)values)", [{return, binary}]) of
[Part1, _, Part3] ->
case string:trim(Part1, leading) of
<<"insert", _/binary>> = InsertSQL ->
{ok, {InsertSQL, Part3}};
<<"INSERT", _/binary>> = InsertSQL ->
{ok, {InsertSQL, Part3}};
_ ->
{error, not_insert_sql}
end;
_ ->
{error, not_insert_sql}
end.

View File

@ -46,16 +46,12 @@
default_port => ?MYSQL_DEFAULT_PORT default_port => ?MYSQL_DEFAULT_PORT
}). }).
-type prepares() :: #{atom() => binary()}. -type template() :: {unicode:chardata(), emqx_template:str()}.
-type params_tokens() :: #{atom() => list()}.
-type sqls() :: #{atom() => binary()}.
-type state() :: -type state() ::
#{ #{
pool_name := binary(), pool_name := binary(),
prepare_statement := prepares(), prepares := ok | {error, _},
params_tokens := params_tokens(), templates := #{{atom(), batch | prepstmt} => template()}
batch_inserts := sqls(),
batch_params_tokens := params_tokens()
}. }.
%%===================================================================== %%=====================================================================
@ -154,13 +150,13 @@ on_query(InstId, {TypeOrKey, SQLOrKey, Params}, State) ->
on_query( on_query(
InstId, InstId,
{TypeOrKey, SQLOrKey, Params, Timeout}, {TypeOrKey, SQLOrKey, Params, Timeout},
#{pool_name := PoolName, prepare_statement := Prepares} = State State
) -> ) ->
MySqlFunction = mysql_function(TypeOrKey), MySqlFunction = mysql_function(TypeOrKey),
{SQLOrKey2, Data} = proc_sql_params(TypeOrKey, SQLOrKey, Params, State), {SQLOrKey2, Data} = proc_sql_params(TypeOrKey, SQLOrKey, Params, State),
case on_sql_query(InstId, MySqlFunction, SQLOrKey2, Data, Timeout, State) of case on_sql_query(InstId, MySqlFunction, SQLOrKey2, Data, Timeout, State) of
{error, not_prepared} -> {error, not_prepared} ->
case maybe_prepare_sql(SQLOrKey2, Prepares, PoolName) of case maybe_prepare_sql(SQLOrKey2, State) of
ok -> ok ->
?tp( ?tp(
mysql_connector_on_query_prepared_sql, mysql_connector_on_query_prepared_sql,
@ -187,23 +183,27 @@ on_query(
on_batch_query( on_batch_query(
InstId, InstId,
BatchReq, BatchReq = [{Key, _} | _],
#{batch_inserts := Inserts, batch_params_tokens := ParamsTokens} = State #{query_templates := Templates} = State
) -> ) ->
case hd(BatchReq) of case maps:get({Key, batch}, Templates, undefined) of
{Key, _} ->
case maps:get(Key, Inserts, undefined) of
undefined -> undefined ->
{error, {unrecoverable_error, batch_select_not_implemented}}; {error, {unrecoverable_error, batch_select_not_implemented}};
InsertSQL -> Template ->
Tokens = maps:get(Key, ParamsTokens), on_batch_insert(InstId, BatchReq, Template, State)
on_batch_insert(InstId, BatchReq, InsertSQL, Tokens, State)
end; end;
Request -> on_batch_query(
LogMeta = #{connector => InstId, first_request => Request, state => State}, InstId,
?SLOG(error, LogMeta#{msg => "invalid request"}), BatchReq,
{error, {unrecoverable_error, invalid_request}} State
end. ) ->
?SLOG(error, #{
msg => "invalid request",
connector => InstId,
request => BatchReq,
state => State
}),
{error, {unrecoverable_error, invalid_request}}.
mysql_function(sql) -> mysql_function(sql) ->
query; query;
@ -222,8 +222,8 @@ on_get_status(_InstId, #{pool_name := PoolName} = State) ->
{ok, NState} -> {ok, NState} ->
%% return new state with prepared statements %% return new state with prepared statements
{connected, NState}; {connected, NState};
{error, {undefined_table, NState}} -> {error, undefined_table} ->
{disconnected, NState, unhealthy_target}; {disconnected, State, unhealthy_target};
{error, _Reason} -> {error, _Reason} ->
%% do not log error, it is logged in prepare_sql_to_conn %% do not log error, it is logged in prepare_sql_to_conn
connecting connecting
@ -238,8 +238,8 @@ do_get_status(Conn) ->
do_check_prepares( do_check_prepares(
#{ #{
pool_name := PoolName, pool_name := PoolName,
prepare_statement := #{send_message := SQL} templates := #{{send_message, prepstmt} := SQL}
} = State }
) -> ) ->
% it's already connected. Verify if target table still exists % it's already connected. Verify if target table still exists
Workers = [Worker || {_WorkerName, Worker} <- ecpool:workers(PoolName)], Workers = [Worker || {_WorkerName, Worker} <- ecpool:workers(PoolName)],
@ -250,7 +250,7 @@ do_check_prepares(
{ok, Conn} -> {ok, Conn} ->
case mysql:prepare(Conn, get_status, SQL) of case mysql:prepare(Conn, get_status, SQL) of
{error, {1146, _, _}} -> {error, {1146, _, _}} ->
{error, {undefined_table, State}}; {error, undefined_table};
{ok, Statement} -> {ok, Statement} ->
mysql:unprepare(Conn, Statement); mysql:unprepare(Conn, Statement);
_ -> _ ->
@ -265,17 +265,14 @@ do_check_prepares(
ok, ok,
Workers Workers
); );
do_check_prepares(#{prepare_statement := Statement}) when is_map(Statement) -> do_check_prepares(#{prepares := ok}) ->
ok; ok;
do_check_prepares(State = #{pool_name := PoolName, prepare_statement := {error, Prepares}}) -> do_check_prepares(#{prepares := {error, _}} = State) ->
%% retry to prepare %% retry to prepare
case prepare_sql(Prepares, PoolName) of case prepare_sql(State) of
ok -> ok ->
%% remove the error %% remove the error
{ok, State#{prepare_statement => Prepares}}; {ok, State#{prepares => ok}};
{error, undefined_table} ->
%% indicate the error
{error, {undefined_table, State#{prepare_statement => {error, Prepares}}}};
{error, Reason} -> {error, Reason} ->
{error, Reason} {error, Reason}
end. end.
@ -285,41 +282,44 @@ do_check_prepares(State = #{pool_name := PoolName, prepare_statement := {error,
connect(Options) -> connect(Options) ->
mysql:start_link(Options). mysql:start_link(Options).
init_prepare(State = #{prepare_statement := Prepares, pool_name := PoolName}) -> init_prepare(State = #{query_templates := Templates}) ->
case maps:size(Prepares) of case maps:size(Templates) of
0 -> 0 ->
State; State#{prepares => ok};
_ -> _ ->
case prepare_sql(Prepares, PoolName) of case prepare_sql(State) of
ok -> ok ->
State; State#{prepares => ok};
{error, Reason} -> {error, Reason} ->
LogMeta = #{msg => <<"mysql_init_prepare_statement_failed">>, reason => Reason}, ?SLOG(error, #{
?SLOG(error, LogMeta), msg => <<"MySQL init prepare statement failed">>,
reason => Reason
}),
%% mark the prepare_statement as failed %% mark the prepare_statement as failed
State#{prepare_statement => {error, Prepares}} State#{prepares => {error, Reason}}
end end
end. end.
maybe_prepare_sql(SQLOrKey, Prepares, PoolName) -> maybe_prepare_sql(SQLOrKey, State = #{query_templates := Templates}) ->
case maps:is_key(SQLOrKey, Prepares) of case maps:is_key({SQLOrKey, prepstmt}, Templates) of
true -> prepare_sql(Prepares, PoolName); true -> prepare_sql(State);
false -> {error, {unrecoverable_error, prepared_statement_invalid}} false -> {error, {unrecoverable_error, prepared_statement_invalid}}
end. end.
prepare_sql(Prepares, PoolName) when is_map(Prepares) -> prepare_sql(#{query_templates := Templates, pool_name := PoolName}) ->
prepare_sql(maps:to_list(Prepares), PoolName); prepare_sql(maps:to_list(Templates), PoolName).
prepare_sql(Prepares, PoolName) ->
case do_prepare_sql(Prepares, PoolName) of prepare_sql(Templates, PoolName) ->
case do_prepare_sql(Templates, PoolName) of
ok -> ok ->
%% prepare for reconnect %% prepare for reconnect
ecpool:add_reconnect_callback(PoolName, {?MODULE, prepare_sql_to_conn, [Prepares]}), ecpool:add_reconnect_callback(PoolName, {?MODULE, prepare_sql_to_conn, [Templates]}),
ok; ok;
{error, R} -> {error, R} ->
{error, R} {error, R}
end. end.
do_prepare_sql(Prepares, PoolName) -> do_prepare_sql(Templates, PoolName) ->
Conns = Conns =
[ [
begin begin
@ -328,33 +328,30 @@ do_prepare_sql(Prepares, PoolName) ->
end end
|| {_Name, Worker} <- ecpool:workers(PoolName) || {_Name, Worker} <- ecpool:workers(PoolName)
], ],
prepare_sql_to_conn_list(Conns, Prepares). prepare_sql_to_conn_list(Conns, Templates).
prepare_sql_to_conn_list([], _PrepareList) -> prepare_sql_to_conn_list([], _Templates) ->
ok; ok;
prepare_sql_to_conn_list([Conn | ConnList], PrepareList) -> prepare_sql_to_conn_list([Conn | ConnList], Templates) ->
case prepare_sql_to_conn(Conn, PrepareList) of case prepare_sql_to_conn(Conn, Templates) of
ok -> ok ->
prepare_sql_to_conn_list(ConnList, PrepareList); prepare_sql_to_conn_list(ConnList, Templates);
{error, R} -> {error, R} ->
%% rollback %% rollback
Fun = fun({Key, _}) -> _ = [unprepare_sql_to_conn(Conn, Template) || Template <- Templates],
_ = unprepare_sql_to_conn(Conn, Key),
ok
end,
lists:foreach(Fun, PrepareList),
{error, R} {error, R}
end. end.
prepare_sql_to_conn(Conn, []) when is_pid(Conn) -> ok; prepare_sql_to_conn(_Conn, []) ->
prepare_sql_to_conn(Conn, [{Key, SQL} | PrepareList]) when is_pid(Conn) -> ok;
LogMeta = #{msg => "mysql_prepare_statement", name => Key, prepare_sql => SQL}, prepare_sql_to_conn(Conn, [{{Key, prepstmt}, {SQL, _RowTemplate}} | Rest]) ->
LogMeta = #{msg => "MySQL Prepare Statement", name => Key, prepare_sql => SQL},
?SLOG(info, LogMeta), ?SLOG(info, LogMeta),
_ = unprepare_sql_to_conn(Conn, Key), _ = unprepare_sql_to_conn(Conn, Key),
case mysql:prepare(Conn, Key, SQL) of case mysql:prepare(Conn, Key, SQL) of
{ok, _Key} -> {ok, _Key} ->
?SLOG(info, LogMeta#{result => success}), ?SLOG(info, LogMeta#{result => success}),
prepare_sql_to_conn(Conn, PrepareList); prepare_sql_to_conn(Conn, Rest);
{error, {1146, _, _} = Reason} -> {error, {1146, _, _} = Reason} ->
%% Target table is not created %% Target table is not created
?tp(mysql_undefined_table, #{}), ?tp(mysql_undefined_table, #{}),
@ -365,84 +362,92 @@ prepare_sql_to_conn(Conn, [{Key, SQL} | PrepareList]) when is_pid(Conn) ->
% syntax failures. Retrying syntax failures is not very productive. % syntax failures. Retrying syntax failures is not very productive.
?SLOG(error, LogMeta#{result => failed, reason => Reason}), ?SLOG(error, LogMeta#{result => failed, reason => Reason}),
{error, Reason} {error, Reason}
end. end;
prepare_sql_to_conn(Conn, [{_Key, _Template} | Rest]) ->
prepare_sql_to_conn(Conn, Rest).
unprepare_sql_to_conn(Conn, PrepareSqlKey) -> unprepare_sql_to_conn(Conn, {{Key, prepstmt}, _}) ->
mysql:unprepare(Conn, PrepareSqlKey). mysql:unprepare(Conn, Key);
unprepare_sql_to_conn(Conn, Key) when is_atom(Key) ->
mysql:unprepare(Conn, Key);
unprepare_sql_to_conn(_Conn, _) ->
ok.
parse_prepare_sql(Config) -> parse_prepare_sql(Config) ->
SQL = Queries =
case maps:get(prepare_statement, Config, undefined) of case Config of
undefined -> #{prepare_statement := Qs} ->
case maps:get(sql, Config, undefined) of Qs;
undefined -> #{}; #{sql := Query} ->
Template -> #{send_message => Template} #{send_message => Query};
end; _ ->
Any -> #{}
Any
end, end,
parse_prepare_sql(maps:to_list(SQL), #{}, #{}, #{}, #{}). Templates = maps:fold(fun parse_prepare_sql/3, #{}, Queries),
#{query_templates => Templates}.
parse_prepare_sql([{Key, H} | _] = L, Prepares, Tokens, BatchInserts, BatchTks) -> parse_prepare_sql(Key, Query, Acc) ->
{PrepareSQL, ParamsTokens} = emqx_placeholder:preproc_sql(H), Template = emqx_template_sql:parse_prepstmt(Query, #{parameters => '?'}),
parse_batch_prepare_sql( AccNext = Acc#{{Key, prepstmt} => Template},
L, Prepares#{Key => PrepareSQL}, Tokens#{Key => ParamsTokens}, BatchInserts, BatchTks parse_batch_sql(Key, Query, AccNext).
);
parse_prepare_sql([], Prepares, Tokens, BatchInserts, BatchTks) ->
#{
prepare_statement => Prepares,
params_tokens => Tokens,
batch_inserts => BatchInserts,
batch_params_tokens => BatchTks
}.
parse_batch_prepare_sql([{Key, H} | T], Prepares, Tokens, BatchInserts, BatchTks) -> parse_batch_sql(Key, Query, Acc) ->
case emqx_utils_sql:get_statement_type(H) of case emqx_utils_sql:get_statement_type(Query) of
select ->
parse_prepare_sql(T, Prepares, Tokens, BatchInserts, BatchTks);
insert -> insert ->
case emqx_utils_sql:parse_insert(H) of case emqx_utils_sql:parse_insert(Query) of
{ok, {InsertSQL, Params}} -> {ok, {Insert, Params}} ->
ParamsTks = emqx_placeholder:preproc_tmpl(Params), RowTemplate = emqx_template_sql:parse(Params),
parse_prepare_sql( Acc#{{Key, batch} => {Insert, RowTemplate}};
T,
Prepares,
Tokens,
BatchInserts#{Key => InsertSQL},
BatchTks#{Key => ParamsTks}
);
{error, Reason} -> {error, Reason} ->
?SLOG(error, #{msg => "split_sql_failed", sql => H, reason => Reason}), ?SLOG(error, #{
parse_prepare_sql(T, Prepares, Tokens, BatchInserts, BatchTks) msg => "parse insert sql statement failed",
sql => Query,
reason => Reason
}),
Acc
end; end;
Type when is_atom(Type) -> select ->
?SLOG(error, #{msg => "detect_sql_type_unsupported", sql => H, type => Type}), Acc;
parse_prepare_sql(T, Prepares, Tokens, BatchInserts, BatchTks); Otherwise ->
{error, Reason} -> ?SLOG(error, #{
?SLOG(error, #{msg => "detect_sql_type_failed", sql => H, reason => Reason}), msg => "invalid sql statement type",
parse_prepare_sql(T, Prepares, Tokens, BatchInserts, BatchTks) sql => Query,
type => Otherwise
}),
Acc
end. end.
proc_sql_params(query, SQLOrKey, Params, _State) -> proc_sql_params(query, SQLOrKey, Params, _State) ->
{SQLOrKey, Params}; {SQLOrKey, Params};
proc_sql_params(prepared_query, SQLOrKey, Params, _State) -> proc_sql_params(prepared_query, SQLOrKey, Params, _State) ->
{SQLOrKey, Params}; {SQLOrKey, Params};
proc_sql_params(TypeOrKey, SQLOrData, Params, #{params_tokens := ParamsTokens}) -> proc_sql_params(TypeOrKey, SQLOrData, Params, #{query_templates := Templates}) ->
case maps:get(TypeOrKey, ParamsTokens, undefined) of case maps:get({TypeOrKey, prepstmt}, Templates, undefined) of
undefined -> undefined ->
{SQLOrData, Params}; {SQLOrData, Params};
Tokens -> {_InsertPart, RowTemplate} ->
{TypeOrKey, emqx_placeholder:proc_sql(Tokens, SQLOrData)} % NOTE
% Ignoring errors here, missing variables are set to `null`.
{Row, _Errors} = emqx_template_sql:render_prepstmt(
RowTemplate,
{emqx_jsonish, SQLOrData}
),
{TypeOrKey, Row}
end. end.
on_batch_insert(InstId, BatchReqs, InsertPart, Tokens, State) -> on_batch_insert(InstId, BatchReqs, {InsertPart, RowTemplate}, State) ->
ValuesPart = lists:join($,, [ Rows = [render_row(RowTemplate, Msg) || {_, Msg} <- BatchReqs],
emqx_placeholder:proc_param_str(Tokens, Msg, fun emqx_placeholder:quote_mysql/1) Query = [InsertPart, <<" values ">> | lists:join($,, Rows)],
|| {_, Msg} <- BatchReqs
]),
Query = [InsertPart, <<" values ">> | ValuesPart],
on_sql_query(InstId, query, Query, no_params, default_timeout, State). on_sql_query(InstId, query, Query, no_params, default_timeout, State).
render_row(RowTemplate, Data) ->
% NOTE
% Ignoring errors here, missing variables are set to "'undefined'" due to backward
% compatibility requirements.
RenderOpts = #{escaping => mysql, undefined => <<"undefined">>},
{Row, _Errors} = emqx_template_sql:render(RowTemplate, {emqx_jsonish, Data}, RenderOpts),
Row.
on_sql_query( on_sql_query(
InstId, InstId,
SQLFunc, SQLFunc,

View File

@ -52,15 +52,12 @@
default_port => ?PGSQL_DEFAULT_PORT default_port => ?PGSQL_DEFAULT_PORT
}). }).
-type prepares() :: #{atom() => binary()}. -type template() :: {unicode:chardata(), emqx_template_sql:row_template()}.
-type params_tokens() :: #{atom() => list()}.
-type state() :: -type state() ::
#{ #{
pool_name := binary(), pool_name := binary(),
prepare_sql := prepares(), query_templates := #{binary() => template()},
params_tokens := params_tokens(), prepares := #{binary() => epgsql:statement()} | {error, _}
prepare_statement := epgsql:statement()
}. }.
%% FIXME: add `{error, sync_required}' to `epgsql:execute_batch' %% FIXME: add `{error, sync_required}' to `epgsql:execute_batch'
@ -142,7 +139,7 @@ on_start(
State = parse_prepare_sql(Config), State = parse_prepare_sql(Config),
case emqx_resource_pool:start(InstId, ?MODULE, Options ++ SslOpts) of case emqx_resource_pool:start(InstId, ?MODULE, Options ++ SslOpts) of
ok -> ok ->
{ok, init_prepare(State#{pool_name => InstId, prepare_statement => #{}})}; {ok, init_prepare(State#{pool_name => InstId, prepares => #{}})};
{error, Reason} -> {error, Reason} ->
?tp( ?tp(
pgsql_connector_start_failed, pgsql_connector_start_failed,
@ -189,55 +186,50 @@ pgsql_query_type(_) ->
on_batch_query( on_batch_query(
InstId, InstId,
BatchReq, [{Key, _} = Request | _] = BatchReq,
#{pool_name := PoolName, params_tokens := Tokens, prepare_statement := Sts} = State #{pool_name := PoolName, query_templates := Templates, prepares := PrepStatements} = State
) -> ) ->
case BatchReq of
[{Key, _} = Request | _] ->
BinKey = to_bin(Key), BinKey = to_bin(Key),
case maps:get(BinKey, Tokens, undefined) of case maps:get(BinKey, Templates, undefined) of
undefined -> undefined ->
Log = #{ Log = #{
connector => InstId, connector => InstId,
first_request => Request, first_request => Request,
state => State, state => State,
msg => "batch_prepare_not_implemented" msg => "batch prepare not implemented"
}, },
?SLOG(error, Log), ?SLOG(error, Log),
{error, {unrecoverable_error, batch_prepare_not_implemented}}; {error, {unrecoverable_error, batch_prepare_not_implemented}};
TokenList -> {_Statement, RowTemplate} ->
{_, Datas} = lists:unzip(BatchReq), PrepStatement = maps:get(BinKey, PrepStatements),
Datas2 = [emqx_placeholder:proc_sql(TokenList, Data) || Data <- Datas], Rows = [render_prepare_sql_row(RowTemplate, Data) || {_Key, Data} <- BatchReq],
St = maps:get(BinKey, Sts), case on_sql_query(InstId, PoolName, execute_batch, PrepStatement, Rows) of
case on_sql_query(InstId, PoolName, execute_batch, St, Datas2) of
{error, _Error} = Result -> {error, _Error} = Result ->
handle_result(Result); handle_result(Result);
{_Column, Results} -> {_Column, Results} ->
handle_batch_result(Results, 0) handle_batch_result(Results, 0)
end end
end; end;
_ -> on_batch_query(InstId, BatchReq, State) ->
Log = #{ ?SLOG(error, #{
connector => InstId, connector => InstId,
request => BatchReq, request => BatchReq,
state => State, state => State,
msg => "invalid_request" msg => "invalid request"
}, }),
?SLOG(error, Log), {error, {unrecoverable_error, invalid_request}}.
{error, {unrecoverable_error, invalid_request}}
end.
proc_sql_params(query, SQLOrKey, Params, _State) -> proc_sql_params(query, SQLOrKey, Params, _State) ->
{SQLOrKey, Params}; {SQLOrKey, Params};
proc_sql_params(prepared_query, SQLOrKey, Params, _State) -> proc_sql_params(prepared_query, SQLOrKey, Params, _State) ->
{SQLOrKey, Params}; {SQLOrKey, Params};
proc_sql_params(TypeOrKey, SQLOrData, Params, #{params_tokens := ParamsTokens}) -> proc_sql_params(TypeOrKey, SQLOrData, Params, #{query_templates := Templates}) ->
Key = to_bin(TypeOrKey), Key = to_bin(TypeOrKey),
case maps:get(Key, ParamsTokens, undefined) of case maps:get(Key, Templates, undefined) of
undefined -> undefined ->
{SQLOrData, Params}; {SQLOrData, Params};
Tokens -> {_Statement, RowTemplate} ->
{Key, emqx_placeholder:proc_sql(Tokens, SQLOrData)} {Key, render_prepare_sql_row(RowTemplate, SQLOrData)}
end. end.
on_sql_query(InstId, PoolName, Type, NameOrSQL, Data) -> on_sql_query(InstId, PoolName, Type, NameOrSQL, Data) ->
@ -297,9 +289,9 @@ on_get_status(_InstId, #{pool_name := PoolName} = State) ->
{ok, NState} -> {ok, NState} ->
%% return new state with prepared statements %% return new state with prepared statements
{connected, NState}; {connected, NState};
{error, {undefined_table, NState}} -> {error, undefined_table} ->
%% return new state indicating that we are connected but the target table is not created %% return new state indicating that we are connected but the target table is not created
{disconnected, NState, unhealthy_target}; {disconnected, State, unhealthy_target};
{error, _Reason} -> {error, _Reason} ->
%% do not log error, it is logged in prepare_sql_to_conn %% do not log error, it is logged in prepare_sql_to_conn
connecting connecting
@ -314,29 +306,26 @@ do_get_status(Conn) ->
do_check_prepares( do_check_prepares(
#{ #{
pool_name := PoolName, pool_name := PoolName,
prepare_sql := #{<<"send_message">> := SQL} query_templates := #{<<"send_message">> := {SQL, _RowTemplate}}
} = State }
) -> ) ->
WorkerPids = [Worker || {_WorkerName, Worker} <- ecpool:workers(PoolName)], WorkerPids = [Worker || {_WorkerName, Worker} <- ecpool:workers(PoolName)],
case validate_table_existence(WorkerPids, SQL) of case validate_table_existence(WorkerPids, SQL) of
ok -> ok ->
ok; ok;
{error, undefined_table} -> {error, Reason} ->
{error, {undefined_table, State}} {error, Reason}
end; end;
do_check_prepares(#{prepare_sql := Prepares}) when is_map(Prepares) -> do_check_prepares(#{prepares := Prepares}) when is_map(Prepares) ->
ok; ok;
do_check_prepares(State = #{pool_name := PoolName, prepare_sql := {error, Prepares}}) -> do_check_prepares(#{prepares := {error, _}} = State) ->
%% retry to prepare %% retry to prepare
case prepare_sql(Prepares, PoolName) of case prepare_sql(State) of
{ok, Sts} -> {ok, PrepStatements} ->
%% remove the error %% remove the error
{ok, State#{prepare_sql => Prepares, prepare_statement := Sts}}; {ok, State#{prepares := PrepStatements}};
{error, undefined_table} -> {error, Reason} ->
%% indicate the error {error, Reason}
{error, {undefined_table, State#{prepare_sql => {error, Prepares}}}};
Error ->
{error, Error}
end. end.
-spec validate_table_existence([pid()], binary()) -> ok | {error, undefined_table}. -spec validate_table_existence([pid()], binary()) -> ok | {error, undefined_table}.
@ -426,69 +415,66 @@ conn_opts([_Opt | Opts], Acc) ->
conn_opts(Opts, Acc). conn_opts(Opts, Acc).
parse_prepare_sql(Config) -> parse_prepare_sql(Config) ->
SQL = Queries =
case maps:get(prepare_statement, Config, undefined) of case Config of
undefined -> #{prepare_statement := Qs} ->
case maps:get(sql, Config, undefined) of Qs;
undefined -> #{}; #{sql := Query} ->
Template -> #{<<"send_message">> => Template} #{<<"send_message">> => Query};
end; #{} ->
Any -> #{}
Any
end, end,
parse_prepare_sql(maps:to_list(SQL), #{}, #{}). Templates = maps:fold(fun parse_prepare_sql/3, #{}, Queries),
#{query_templates => Templates}.
parse_prepare_sql([{Key, H} | T], Prepares, Tokens) -> parse_prepare_sql(Key, Query, Acc) ->
{PrepareSQL, ParamsTokens} = emqx_placeholder:preproc_sql(H, '$n'), Template = emqx_template_sql:parse_prepstmt(Query, #{parameters => '$n'}),
parse_prepare_sql( Acc#{Key => Template}.
T, Prepares#{Key => PrepareSQL}, Tokens#{Key => ParamsTokens}
);
parse_prepare_sql([], Prepares, Tokens) ->
#{
prepare_sql => Prepares,
params_tokens => Tokens
}.
init_prepare(State = #{prepare_sql := Prepares, pool_name := PoolName}) -> render_prepare_sql_row(RowTemplate, Data) ->
case maps:size(Prepares) of % NOTE: ignoring errors here, missing variables will be replaced with `null`.
0 -> {Row, _Errors} = emqx_template_sql:render_prepstmt(RowTemplate, {emqx_jsonish, Data}),
Row.
init_prepare(State = #{query_templates := Templates}) when map_size(Templates) == 0 ->
State; State;
_ -> init_prepare(State = #{}) ->
case prepare_sql(Prepares, PoolName) of case prepare_sql(State) of
{ok, Sts} -> {ok, PrepStatements} ->
State#{prepare_statement := Sts}; State#{prepares => PrepStatements};
Error -> Error ->
LogMsg = ?SLOG(
error,
maps:merge( maps:merge(
#{msg => <<"postgresql_init_prepare_statement_failed">>}, #{msg => <<"postgresql_init_prepare_statement_failed">>},
translate_to_log_context(Error) translate_to_log_context(Error)
)
), ),
?SLOG(error, LogMsg), %% mark the prepares failed
%% mark the prepare_sql as failed State#{prepares => Error}
State#{prepare_sql => {error, Prepares}}
end
end. end.
prepare_sql(Prepares, PoolName) when is_map(Prepares) -> prepare_sql(#{query_templates := Templates, pool_name := PoolName}) ->
prepare_sql(maps:to_list(Prepares), PoolName); prepare_sql(maps:to_list(Templates), PoolName).
prepare_sql(Prepares, PoolName) ->
case do_prepare_sql(Prepares, PoolName) of prepare_sql(Templates, PoolName) ->
case do_prepare_sql(Templates, PoolName) of
{ok, _Sts} = Ok -> {ok, _Sts} = Ok ->
%% prepare for reconnect %% prepare for reconnect
ecpool:add_reconnect_callback(PoolName, {?MODULE, prepare_sql_to_conn, [Prepares]}), ecpool:add_reconnect_callback(PoolName, {?MODULE, prepare_sql_to_conn, [Templates]}),
Ok; Ok;
Error -> Error ->
Error Error
end. end.
do_prepare_sql(Prepares, PoolName) -> do_prepare_sql(Templates, PoolName) ->
do_prepare_sql(ecpool:workers(PoolName), Prepares, #{}). do_prepare_sql(ecpool:workers(PoolName), Templates, #{}).
do_prepare_sql([{_Name, Worker} | T], Prepares, _LastSts) -> do_prepare_sql([{_Name, Worker} | Rest], Templates, _LastSts) ->
{ok, Conn} = ecpool_worker:client(Worker), {ok, Conn} = ecpool_worker:client(Worker),
case prepare_sql_to_conn(Conn, Prepares) of case prepare_sql_to_conn(Conn, Templates) of
{ok, Sts} -> {ok, Sts} ->
do_prepare_sql(T, Prepares, Sts); do_prepare_sql(Rest, Templates, Sts);
Error -> Error ->
Error Error
end; end;
@ -498,13 +484,14 @@ do_prepare_sql([], _Prepares, LastSts) ->
prepare_sql_to_conn(Conn, Prepares) -> prepare_sql_to_conn(Conn, Prepares) ->
prepare_sql_to_conn(Conn, Prepares, #{}). prepare_sql_to_conn(Conn, Prepares, #{}).
prepare_sql_to_conn(Conn, [], Statements) when is_pid(Conn) -> {ok, Statements}; prepare_sql_to_conn(Conn, [], Statements) when is_pid(Conn) ->
prepare_sql_to_conn(Conn, [{Key, SQL} | PrepareList], Statements) when is_pid(Conn) -> {ok, Statements};
LogMeta = #{msg => "postgresql_prepare_statement", name => Key, prepare_sql => SQL}, prepare_sql_to_conn(Conn, [{Key, {SQL, _RowTemplate}} | Rest], Statements) when is_pid(Conn) ->
LogMeta = #{msg => "postgresql_prepare_statement", name => Key, sql => SQL},
?SLOG(info, LogMeta), ?SLOG(info, LogMeta),
case epgsql:parse2(Conn, Key, SQL, []) of case epgsql:parse2(Conn, Key, SQL, []) of
{ok, Statement} -> {ok, Statement} ->
prepare_sql_to_conn(Conn, PrepareList, Statements#{Key => Statement}); prepare_sql_to_conn(Conn, Rest, Statements#{Key => Statement});
{error, {error, error, _, undefined_table, _, _} = Error} -> {error, {error, error, _, undefined_table, _, _} = Error} ->
%% Target table is not created %% Target table is not created
?tp(pgsql_undefined_table, #{}), ?tp(pgsql_undefined_table, #{}),

View File

@ -2,7 +2,7 @@
{application, emqx_prometheus, [ {application, emqx_prometheus, [
{description, "Prometheus for EMQX"}, {description, "Prometheus for EMQX"},
% strict semver, bump manually! % strict semver, bump manually!
{vsn, "5.0.16"}, {vsn, "5.0.17"},
{modules, []}, {modules, []},
{registered, [emqx_prometheus_sup]}, {registered, [emqx_prometheus_sup]},
{applications, [kernel, stdlib, prometheus, emqx, emqx_management]}, {applications, [kernel, stdlib, prometheus, emqx, emqx_management]},

View File

@ -24,7 +24,6 @@
-include("emqx_prometheus.hrl"). -include("emqx_prometheus.hrl").
-include_lib("prometheus/include/prometheus.hrl").
-include_lib("prometheus/include/prometheus_model.hrl"). -include_lib("prometheus/include/prometheus_model.hrl").
-include_lib("emqx/include/logger.hrl"). -include_lib("emqx/include/logger.hrl").
@ -114,16 +113,20 @@ handle_info(_Msg, State) ->
push_to_push_gateway(Uri, Headers, JobName) when is_list(Headers) -> push_to_push_gateway(Uri, Headers, JobName) when is_list(Headers) ->
[Name, Ip] = string:tokens(atom_to_list(node()), "@"), [Name, Ip] = string:tokens(atom_to_list(node()), "@"),
JobName1 = emqx_placeholder:preproc_tmpl(JobName), % NOTE: allowing errors here to keep rough backward compatibility
JobName2 = binary_to_list( {JobName1, Errors} = emqx_template:render(
emqx_placeholder:proc_tmpl( emqx_template:parse(JobName),
JobName1,
#{<<"name">> => Name, <<"host">> => Ip} #{<<"name">> => Name, <<"host">> => Ip}
)
), ),
_ =
Url = lists:concat([Uri, "/metrics/job/", JobName2]), Errors == [] orelse
?SLOG(warning, #{
msg => "prometheus_job_name_template_invalid",
errors => Errors,
template => JobName
}),
Data = prometheus_text_format:format(), Data = prometheus_text_format:format(),
Url = lists:concat([Uri, "/metrics/job/", unicode:characters_to_list(JobName1)]),
case httpc:request(post, {Url, Headers, "text/plain", Data}, ?HTTP_OPTIONS, []) of case httpc:request(post, {Url, Headers, "text/plain", Data}, ?HTTP_OPTIONS, []) of
{ok, {{"HTTP/1.1", 200, _}, _RespHeaders, _RespBody}} -> {ok, {{"HTTP/1.1", 200, _}, _RespHeaders, _RespBody}} ->
ok; ok;

View File

@ -82,23 +82,18 @@ pre_process_action_args(
qos := QoS, qos := QoS,
retain := Retain, retain := Retain,
payload := Payload, payload := Payload,
mqtt_properties := MQTTPropertiesTemplate0, mqtt_properties := MQTTProperties,
user_properties := UserPropertiesTemplate user_properties := UserProperties
} = Args } = Args
) -> ) ->
MQTTPropertiesTemplate =
maps:map(
fun(_Key, V) -> emqx_placeholder:preproc_tmpl(V) end,
MQTTPropertiesTemplate0
),
Args#{ Args#{
preprocessed_tmpl => #{ preprocessed_tmpl => #{
topic => emqx_placeholder:preproc_tmpl(Topic), topic => emqx_template:parse(Topic),
qos => preproc_vars(QoS), qos => parse_simple_var(QoS),
retain => preproc_vars(Retain), retain => parse_simple_var(Retain),
payload => emqx_placeholder:preproc_tmpl(Payload), payload => parse_payload(Payload),
mqtt_properties => MQTTPropertiesTemplate, mqtt_properties => parse_mqtt_properties(MQTTProperties),
user_properties => preproc_user_properties(UserPropertiesTemplate) user_properties => parse_user_properties(UserProperties)
} }
}; };
pre_process_action_args(_, Args) -> pre_process_action_args(_, Args) ->
@ -131,25 +126,28 @@ republish(
#{metadata := #{rule_id := RuleId}} = Env, #{metadata := #{rule_id := RuleId}} = Env,
#{ #{
preprocessed_tmpl := #{ preprocessed_tmpl := #{
qos := QoSTks, qos := QoSTemplate,
retain := RetainTks, retain := RetainTemplate,
topic := TopicTks, topic := TopicTemplate,
payload := PayloadTks, payload := PayloadTemplate,
mqtt_properties := MQTTPropertiesTemplate, mqtt_properties := MQTTPropertiesTemplate,
user_properties := UserPropertiesTks user_properties := UserPropertiesTemplate
} }
} }
) -> ) ->
Topic = emqx_placeholder:proc_tmpl(TopicTks, Selected), % NOTE: rendering missing bindings as string "undefined"
Payload = format_msg(PayloadTks, Selected), {TopicString, _Errors1} = render_template(TopicTemplate, Selected),
QoS = replace_simple_var(QoSTks, Selected, 0), {PayloadString, _Errors2} = render_template(PayloadTemplate, Selected),
Retain = replace_simple_var(RetainTks, Selected, false), Topic = iolist_to_binary(TopicString),
Payload = iolist_to_binary(PayloadString),
QoS = render_simple_var(QoSTemplate, Selected, 0),
Retain = render_simple_var(RetainTemplate, Selected, false),
%% 'flags' is set for message re-publishes or message related %% 'flags' is set for message re-publishes or message related
%% events such as message.acked and message.dropped %% events such as message.acked and message.dropped
Flags0 = maps:get(flags, Env, #{}), Flags0 = maps:get(flags, Env, #{}),
Flags = Flags0#{retain => Retain}, Flags = Flags0#{retain => Retain},
PubProps0 = format_pub_props(UserPropertiesTks, Selected, Env), PubProps0 = render_pub_props(UserPropertiesTemplate, Selected, Env),
MQTTProps = format_mqtt_properties(MQTTPropertiesTemplate, Selected, Env), MQTTProps = render_mqtt_properties(MQTTPropertiesTemplate, Selected, Env),
PubProps = maps:merge(PubProps0, MQTTProps), PubProps = maps:merge(PubProps0, MQTTProps),
?TRACE( ?TRACE(
"RULE", "RULE",
@ -220,79 +218,90 @@ safe_publish(RuleId, Topic, QoS, Flags, Payload, PubProps) ->
_ = emqx_broker:safe_publish(Msg), _ = emqx_broker:safe_publish(Msg),
emqx_metrics:inc_msg(Msg). emqx_metrics:inc_msg(Msg).
preproc_vars(Data) when is_binary(Data) -> parse_simple_var(Data) when is_binary(Data) ->
emqx_placeholder:preproc_tmpl(Data); emqx_template:parse(Data);
preproc_vars(Data) -> parse_simple_var(Data) ->
Data. {const, Data}.
preproc_user_properties(<<"${pub_props.'User-Property'}">>) -> parse_payload(Payload) ->
case string:is_empty(Payload) of
false -> emqx_template:parse(Payload);
true -> emqx_template:parse("${.}")
end.
parse_mqtt_properties(MQTTPropertiesTemplate) ->
maps:map(
fun(_Key, V) -> emqx_template:parse(V) end,
MQTTPropertiesTemplate
).
parse_user_properties(<<"${pub_props.'User-Property'}">>) ->
%% keep the original %% keep the original
%% avoid processing this special variable because %% avoid processing this special variable because
%% we do not want to force users to select the value %% we do not want to force users to select the value
%% the value will be taken from Env.pub_props directly %% the value will be taken from Env.pub_props directly
?ORIGINAL_USER_PROPERTIES; ?ORIGINAL_USER_PROPERTIES;
preproc_user_properties(<<"${", _/binary>> = V) -> parse_user_properties(<<"${", _/binary>> = V) ->
%% use a variable %% use a variable
emqx_placeholder:preproc_tmpl(V); emqx_template:parse(V);
preproc_user_properties(_) -> parse_user_properties(_) ->
%% invalid, discard %% invalid, discard
undefined. undefined.
replace_simple_var(Tokens, Data, Default) when is_list(Tokens) -> render_template(Template, Bindings) ->
[Var] = emqx_placeholder:proc_tmpl(Tokens, Data, #{return => rawlist}), emqx_template:render(Template, {emqx_jsonish, Bindings}).
case Var of
render_simple_var([{var, _Name, Accessor}], Data, Default) ->
case emqx_jsonish:lookup(Accessor, Data) of
{ok, Var} -> Var;
%% cannot find the variable from Data %% cannot find the variable from Data
undefined -> Default; {error, _} -> Default
_ -> Var
end; end;
replace_simple_var(Val, _Data, _Default) -> render_simple_var({const, Val}, _Data, _Default) ->
Val. Val.
format_msg([], Selected) -> render_pub_props(UserPropertiesTemplate, Selected, Env) ->
emqx_utils_json:encode(Selected);
format_msg(Tokens, Selected) ->
emqx_placeholder:proc_tmpl(Tokens, Selected).
format_pub_props(UserPropertiesTks, Selected, Env) ->
UserProperties = UserProperties =
case UserPropertiesTks of case UserPropertiesTemplate of
?ORIGINAL_USER_PROPERTIES -> ?ORIGINAL_USER_PROPERTIES ->
maps:get('User-Property', maps:get(pub_props, Env, #{}), #{}); maps:get('User-Property', maps:get(pub_props, Env, #{}), #{});
undefined -> undefined ->
#{}; #{};
_ -> _ ->
replace_simple_var(UserPropertiesTks, Selected, #{}) render_simple_var(UserPropertiesTemplate, Selected, #{})
end, end,
#{'User-Property' => UserProperties}. #{'User-Property' => UserProperties}.
format_mqtt_properties(MQTTPropertiesTemplate, Selected, Env) -> %%
#{metadata := #{rule_id := RuleId}} = Env,
MQTTProperties0 = -define(BADPROP(K, REASON, ENV, DATA),
maps:fold(
fun(K, Template, Acc) ->
try
V = emqx_placeholder:proc_tmpl(Template, Selected),
Acc#{K => V}
catch
Kind:Error ->
?SLOG( ?SLOG(
debug, debug,
#{ DATA#{
msg => "bad_mqtt_property_value_ignored", msg => "bad_mqtt_property_value_ignored",
rule_id => RuleId, rule_id => emqx_utils_maps:deep_get([metadata, rule_id], ENV, undefined),
exception => Kind, reason => REASON,
reason => Error, property => K
property => K,
selected => Selected
} }
), )
Acc ).
end
render_mqtt_properties(MQTTPropertiesTemplate, Selected, Env) ->
MQTTProperties =
maps:map(
fun(K, Template) ->
{V, Errors} = render_template(Template, Selected),
case Errors of
[] ->
ok;
Errors ->
?BADPROP(K, Errors, Env, #{selected => Selected})
end,
iolist_to_binary(V)
end, end,
#{},
MQTTPropertiesTemplate MQTTPropertiesTemplate
), ),
coerce_properties_values(MQTTProperties0, Env). coerce_properties_values(MQTTProperties, Env).
ensure_int(B) when is_binary(B) -> ensure_int(B) when is_binary(B) ->
try try
@ -304,42 +313,24 @@ ensure_int(B) when is_binary(B) ->
ensure_int(I) when is_integer(I) -> ensure_int(I) when is_integer(I) ->
I. I.
coerce_properties_values(MQTTProperties, #{metadata := #{rule_id := RuleId}}) -> coerce_properties_values(MQTTProperties, Env) ->
maps:fold( maps:filtermap(
fun(K, V0, Acc) -> fun(K, V) ->
try try
V = encode_mqtt_property(K, V0), {true, encode_mqtt_property(K, V)}
Acc#{K => V}
catch catch
throw:bad_integer -> throw:Reason ->
?SLOG( ?BADPROP(K, Reason, Env, #{value => V}),
debug, false;
#{
msg => "bad_mqtt_property_value_ignored",
rule_id => RuleId,
reason => bad_integer,
property => K,
value => V0
}
),
Acc;
Kind:Reason:Stacktrace -> Kind:Reason:Stacktrace ->
?SLOG( ?BADPROP(K, Reason, Env, #{
debug, value => V,
#{
msg => "bad_mqtt_property_value_ignored",
rule_id => RuleId,
exception => Kind, exception => Kind,
reason => Reason,
property => K,
value => V0,
stacktrace => Stacktrace stacktrace => Stacktrace
} }),
), false
Acc
end end
end, end,
#{},
MQTTProperties MQTTProperties
). ).

View File

@ -5,7 +5,16 @@
{vsn, "5.0.28"}, {vsn, "5.0.28"},
{modules, []}, {modules, []},
{registered, [emqx_rule_engine_sup, emqx_rule_engine]}, {registered, [emqx_rule_engine_sup, emqx_rule_engine]},
{applications, [kernel, stdlib, rulesql, getopt, emqx_ctl, uuid]}, {applications, [
kernel,
stdlib,
rulesql,
getopt,
uuid,
emqx,
emqx_utils,
emqx_ctl
]},
{mod, {emqx_rule_engine_app, []}}, {mod, {emqx_rule_engine_app, []}},
{env, []}, {env, []},
{licenses, ["Apache-2.0"]}, {licenses, ["Apache-2.0"]},

View File

@ -81,6 +81,7 @@ groups() ->
t_sqlselect_3, t_sqlselect_3,
t_sqlselect_message_publish_event_keep_original_props_1, t_sqlselect_message_publish_event_keep_original_props_1,
t_sqlselect_message_publish_event_keep_original_props_2, t_sqlselect_message_publish_event_keep_original_props_2,
t_sqlselect_missing_template_vars_render_as_undefined,
t_sqlparse_event_1, t_sqlparse_event_1,
t_sqlparse_event_2, t_sqlparse_event_2,
t_sqlparse_event_3, t_sqlparse_event_3,
@ -1364,14 +1365,13 @@ t_sqlselect_inject_props(_Config) ->
actions => [Repub] actions => [Repub]
} }
), ),
Props = user_properties(#{<<"inject_key">> => <<"inject_val">>}),
{ok, Client} = emqtt:start_link([{username, <<"emqx">>}, {proto_ver, v5}]), {ok, Client} = emqtt:start_link([{username, <<"emqx">>}, {proto_ver, v5}]),
{ok, _} = emqtt:connect(Client), {ok, _} = emqtt:connect(Client),
{ok, _, _} = emqtt:subscribe(Client, <<"t2">>, 0), {ok, _, _} = emqtt:subscribe(Client, <<"t2">>, 0),
emqtt:publish(Client, <<"t1">>, #{}, <<"{\"x\":1}">>, [{qos, 0}]), emqtt:publish(Client, <<"t1">>, #{}, <<"{\"x\":1}">>, [{qos, 0}]),
receive receive
{publish, #{topic := T, payload := Payload, properties := Props2}} -> {publish, #{topic := T, payload := Payload, properties := Props}} ->
?assertEqual(Props, Props2), ?assertEqual(user_properties(#{<<"inject_key">> => <<"inject_val">>}), Props),
?assertEqual(<<"t2">>, T), ?assertEqual(<<"t2">>, T),
?assertEqual(<<"{\"x\":1}">>, Payload) ?assertEqual(<<"{\"x\":1}">>, Payload)
after 2000 -> after 2000 ->
@ -1947,6 +1947,32 @@ t_sqlselect_as_put(_Config) ->
PayloadMap2 PayloadMap2
). ).
t_sqlselect_missing_template_vars_render_as_undefined(_Config) ->
SQL = <<"SELECT * FROM \"$events/client_connected\"">>,
Repub = republish_action(<<"t2">>, <<"${clientid}:${missing.var}">>),
{ok, TopicRule} = emqx_rule_engine:create_rule(
#{
sql => SQL,
id => ?TMP_RULEID,
actions => [Repub]
}
),
{ok, Client1} = emqtt:start_link([{clientid, <<"sub-01">>}]),
{ok, _} = emqtt:connect(Client1),
{ok, _, _} = emqtt:subscribe(Client1, <<"t2">>),
{ok, Client2} = emqtt:start_link([{clientid, <<"pub-02">>}]),
{ok, _} = emqtt:connect(Client2),
emqtt:publish(Client2, <<"foo/bar/1">>, <<>>),
receive
{publish, Msg} ->
?assertMatch(#{topic := <<"t2">>, payload := <<"pub-02:undefined">>}, Msg)
after 2000 ->
ct:fail(wait_for_t2)
end,
emqtt:stop(Client2),
emqtt:stop(Client1),
delete_rule(TopicRule).
t_sqlparse_event_1(_Config) -> t_sqlparse_event_1(_Config) ->
Sql = Sql =
"select topic as tp " "select topic as tp "

View File

@ -0,0 +1,72 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2020-2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_jsonish).
-behaviour(emqx_template).
-export([lookup/2]).
-export_type([t/0]).
%% @doc Either a map or a JSON serial.
%% Think of it as a kind of lazily parsed and/or constructed JSON.
-type t() :: propmap() | serial().
%% @doc JSON in serialized form.
-type serial() :: binary().
-type propmap() :: #{prop() => value()}.
-type prop() :: atom() | binary().
-type value() :: scalar() | [scalar() | propmap()] | t().
-type scalar() :: atom() | unicode:chardata() | number().
%%
%% @doc Lookup a value in the JSON-ish map accessible through the given accessor.
%% If accessor implies drilling down into a binary, it will be treated as JSON serial.
%% Failure to parse the binary as JSON will result in an _invalid type_ error.
%% Nested JSON is NOT parsed recursively.
-spec lookup(emqx_template:accessor(), t()) ->
{ok, value()}
| {error, undefined | {_Location :: non_neg_integer(), _InvalidType :: atom()}}.
lookup(Var, Jsonish) ->
lookup(0, _Decoded = false, Var, Jsonish).
lookup(_, _, [], Value) ->
{ok, Value};
lookup(Loc, Decoded, [Prop | Rest], Jsonish) when is_map(Jsonish) ->
case emqx_template:lookup(Prop, Jsonish) of
{ok, Value} ->
lookup(Loc + 1, Decoded, Rest, Value);
{error, Reason} ->
{error, Reason}
end;
lookup(Loc, _Decoded = false, Props, Json) when is_binary(Json) ->
try emqx_utils_json:decode(Json) of
Value ->
% NOTE: This is intentional, we don't want to parse nested JSON.
lookup(Loc, true, Props, Value)
catch
error:_ ->
{error, {Loc, binary}}
end;
lookup(Loc, _, _, Invalid) ->
{error, {Loc, type_name(Invalid)}}.
type_name(Term) when is_atom(Term) -> atom;
type_name(Term) when is_number(Term) -> number;
type_name(Term) when is_binary(Term) -> binary;
type_name(Term) when is_list(Term) -> list.

View File

@ -249,15 +249,15 @@ bin(Val) -> emqx_utils_conv:bin(Val).
-spec quote_sql(_Value) -> iolist(). -spec quote_sql(_Value) -> iolist().
quote_sql(Str) -> quote_sql(Str) ->
emqx_utils_sql:to_sql_string(Str, #{escaping => sql}). emqx_utils_sql:to_sql_string(Str, #{escaping => sql, undefined => <<"undefined">>}).
-spec quote_cql(_Value) -> iolist(). -spec quote_cql(_Value) -> iolist().
quote_cql(Str) -> quote_cql(Str) ->
emqx_utils_sql:to_sql_string(Str, #{escaping => cql}). emqx_utils_sql:to_sql_string(Str, #{escaping => cql, undefined => <<"undefined">>}).
-spec quote_mysql(_Value) -> iolist(). -spec quote_mysql(_Value) -> iolist().
quote_mysql(Str) -> quote_mysql(Str) ->
emqx_utils_sql:to_sql_string(Str, #{escaping => mysql}). emqx_utils_sql:to_sql_string(Str, #{escaping => mysql, undefined => <<"undefined">>}).
lookup_var(Var, Value) when Var == ?PH_VAR_THIS orelse Var == [] -> lookup_var(Var, Value) when Var == ?PH_VAR_THIS orelse Var == [] ->
Value; Value;

View File

@ -0,0 +1,386 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2020-2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_template).
-export([parse/1]).
-export([parse/2]).
-export([parse_deep/1]).
-export([parse_deep/2]).
-export([validate/2]).
-export([is_const/1]).
-export([unparse/1]).
-export([render/2]).
-export([render/3]).
-export([render_strict/2]).
-export([render_strict/3]).
-export([lookup_var/2]).
-export([lookup/2]).
-export([to_string/1]).
-export_type([t/0]).
-export_type([str/0]).
-export_type([deep/0]).
-export_type([placeholder/0]).
-export_type([varname/0]).
-export_type([bindings/0]).
-export_type([accessor/0]).
-export_type([context/0]).
-export_type([render_opts/0]).
-type t() :: str() | {'$tpl', deeptpl()}.
-type str() :: [iodata() | byte() | placeholder()].
-type deep() :: {'$tpl', deeptpl()}.
-type deeptpl() ::
t()
| #{deeptpl() => deeptpl()}
| {list, [deeptpl()]}
| {tuple, [deeptpl()]}
| scalar()
| function()
| pid()
| port()
| reference().
-type placeholder() :: {var, varname(), accessor()}.
-type accessor() :: [binary()].
-type varname() :: string().
-type scalar() :: atom() | unicode:chardata() | number().
-type binding() :: scalar() | list(scalar()) | bindings().
-type bindings() :: #{atom() | binary() => binding()}.
-type reason() :: undefined | {location(), _InvalidType :: atom()}.
-type location() :: non_neg_integer().
-type var_trans() ::
fun((Value :: term()) -> unicode:chardata())
| fun((varname(), Value :: term()) -> unicode:chardata()).
-type parse_opts() :: #{
strip_double_quote => boolean()
}.
-type render_opts() :: #{
var_trans => var_trans()
}.
-type context() ::
%% Map with (potentially nested) bindings.
bindings()
%% Arbitrary term accessible via an access module with `lookup/2` function.
| {_AccessModule :: module(), _Bindings}.
%% Access module API
-callback lookup(accessor(), _Bindings) -> {ok, _Value} | {error, reason()}.
-define(RE_PLACEHOLDER, "\\$\\{[.]?([a-zA-Z0-9._]*)\\}").
-define(RE_ESCAPE, "\\$\\{(\\$)\\}").
%% @doc Parse a unicode string into a template.
%% String might contain zero or more of placeholders in the form of `${var}`,
%% where `var` is a _location_ (possibly deeply nested) of some value in the
%% bindings map.
%% String might contain special escaped form `$${...}` which interpreted as a
%% literal `${...}`.
-spec parse(String :: unicode:chardata()) ->
t().
parse(String) ->
parse(String, #{}).
-spec parse(String :: unicode:chardata(), parse_opts()) ->
t().
parse(String, Opts) ->
RE =
case Opts of
#{strip_double_quote := true} ->
<<"((?|" ?RE_PLACEHOLDER "|\"" ?RE_PLACEHOLDER "\")|" ?RE_ESCAPE ")">>;
#{} ->
<<"(" ?RE_PLACEHOLDER "|" ?RE_ESCAPE ")">>
end,
Splits = re:split(String, RE, [{return, binary}, group, trim, unicode]),
lists:flatmap(fun parse_split/1, Splits).
parse_split([Part, _PH, Var, <<>>]) ->
% Regular placeholder
prepend(Part, [{var, unicode:characters_to_list(Var), parse_accessor(Var)}]);
parse_split([Part, _Escape, <<>>, <<"$">>]) ->
% Escaped literal `$`.
% Use single char as token so the `unparse/1` function can distinguish escaped `$`.
prepend(Part, [$$]);
parse_split([Tail]) ->
[Tail].
prepend(<<>>, To) ->
To;
prepend(Head, To) ->
[Head | To].
parse_accessor(Var) ->
case string:split(Var, <<".">>, all) of
[<<>>] ->
[];
Name ->
Name
end.
%% @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()) ->
ok | {error, [_Error :: {varname(), disallowed}]}.
validate(Allowed, Template) ->
{_, Errors} = render(Template, #{}),
{Used, _} = lists:unzip(Errors),
case lists:usort(Used) -- Allowed of
[] ->
ok;
Disallowed ->
{error, [{Var, disallowed} || Var <- Disallowed]}
end.
%% @doc Check if a template is constant with respect to rendering, i.e. does not
%% contain any placeholders.
-spec is_const(t()) ->
boolean().
is_const(Template) ->
validate([], Template) == ok.
%% @doc Restore original term from a parsed template.
-spec unparse(t()) ->
term().
unparse({'$tpl', Template}) ->
unparse_deep(Template);
unparse(Template) ->
unicode:characters_to_list(lists:map(fun unparse_part/1, Template)).
unparse_part({var, Name, _Accessor}) ->
render_placeholder(Name);
unparse_part($$) ->
<<"${$}">>;
unparse_part(Part) ->
Part.
render_placeholder(Name) ->
"${" ++ Name ++ "}".
%% @doc Render a template with given bindings.
%% Returns a term with all placeholders replaced with values from bindings.
%% If one or more placeholders are not found in bindings, an error is returned.
%% By default, all binding values are converted to strings using `to_string/1`
%% function. Option `var_trans` can be used to override this behaviour.
-spec render(t(), context()) ->
{term(), [_Error :: {varname(), reason()}]}.
render(Template, Context) ->
render(Template, Context, #{}).
-spec render(t(), context(), render_opts()) ->
{term(), [_Error :: {varname(), undefined}]}.
render(Template, Context, Opts) when is_list(Template) ->
lists:mapfoldl(
fun
({var, Name, Accessor}, EAcc) ->
{String, Errors} = render_binding(Name, Accessor, Context, Opts),
{String, Errors ++ EAcc};
(String, EAcc) ->
{String, EAcc}
end,
[],
Template
);
render({'$tpl', Template}, Context, Opts) ->
render_deep(Template, Context, Opts).
render_binding(Name, Accessor, Context, Opts) ->
case lookup_value(Accessor, Context) of
{ok, Value} ->
{render_value(Name, Value, Opts), []};
{error, Reason} ->
% TODO
% Currently, it's not possible to distinguish between a missing value
% and an atom `undefined` in `TransFun`.
{render_value(Name, undefined, Opts), [{Name, Reason}]}
end.
lookup_value(Accessor, {AccessMod, Bindings}) ->
AccessMod:lookup(Accessor, Bindings);
lookup_value(Accessor, Bindings) ->
lookup_var(Accessor, Bindings).
render_value(_Name, Value, #{var_trans := TransFun}) when is_function(TransFun, 1) ->
TransFun(Value);
render_value(Name, Value, #{var_trans := TransFun}) when is_function(TransFun, 2) ->
TransFun(Name, Value);
render_value(_Name, Value, #{}) ->
to_string(Value).
%% @doc Render a template with given bindings.
%% Behaves like `render/2`, but raises an error exception if one or more placeholders
%% are not found in the bindings.
-spec render_strict(t(), context()) ->
term().
render_strict(Template, Context) ->
render_strict(Template, Context, #{}).
-spec render_strict(t(), context(), render_opts()) ->
term().
render_strict(Template, Context, Opts) ->
case render(Template, Context, Opts) of
{Render, []} ->
Render;
{_, Errors = [_ | _]} ->
error(Errors, [unparse(Template), Context])
end.
%% @doc Parse an arbitrary Erlang term into a "deep" template.
%% Any binaries nested in the term are treated as string templates, while
%% lists are not analyzed for "printability" and are treated as nested terms.
%% The result is a usual template, and can be fed to other functions in this
%% module.
-spec parse_deep(term()) ->
t().
parse_deep(Term) ->
parse_deep(Term, #{}).
-spec parse_deep(term(), parse_opts()) ->
t().
parse_deep(Term, Opts) ->
{'$tpl', parse_deep_term(Term, Opts)}.
parse_deep_term(Term, Opts) when is_map(Term) ->
maps:fold(
fun(K, V, Acc) ->
Acc#{parse_deep_term(K, Opts) => parse_deep_term(V, Opts)}
end,
#{},
Term
);
parse_deep_term(Term, Opts) when is_list(Term) ->
{list, [parse_deep_term(E, Opts) || E <- Term]};
parse_deep_term(Term, Opts) when is_tuple(Term) ->
{tuple, [parse_deep_term(E, Opts) || E <- tuple_to_list(Term)]};
parse_deep_term(Term, Opts) when is_binary(Term) ->
parse(Term, Opts);
parse_deep_term(Term, _Opts) ->
Term.
render_deep(Template, Context, Opts) when is_map(Template) ->
maps:fold(
fun(KT, VT, {Acc, Errors}) ->
{K, KErrors} = render_deep(KT, Context, Opts),
{V, VErrors} = render_deep(VT, Context, Opts),
{Acc#{K => V}, KErrors ++ VErrors ++ Errors}
end,
{#{}, []},
Template
);
render_deep({list, Template}, Context, Opts) when is_list(Template) ->
lists:mapfoldr(
fun(T, Errors) ->
{E, VErrors} = render_deep(T, Context, Opts),
{E, VErrors ++ Errors}
end,
[],
Template
);
render_deep({tuple, Template}, Context, Opts) when is_list(Template) ->
{Term, Errors} = render_deep({list, Template}, Context, Opts),
{list_to_tuple(Term), Errors};
render_deep(Template, Context, Opts) when is_list(Template) ->
{String, Errors} = render(Template, Context, Opts),
{unicode:characters_to_binary(String), Errors};
render_deep(Term, _Bindings, _Opts) ->
{Term, []}.
unparse_deep(Template) when is_map(Template) ->
maps:fold(
fun(K, V, Acc) ->
Acc#{unparse_deep(K) => unparse_deep(V)}
end,
#{},
Template
);
unparse_deep({list, Template}) when is_list(Template) ->
[unparse_deep(E) || E <- Template];
unparse_deep({tuple, Template}) when is_list(Template) ->
list_to_tuple(unparse_deep({list, Template}));
unparse_deep(Template) when is_list(Template) ->
unicode:characters_to_binary(unparse(Template));
unparse_deep(Term) ->
Term.
%%
%% @doc Lookup a variable in the bindings accessible through the accessor.
%% Lookup is "loose" in the sense that atom and binary keys in the bindings are
%% treated equally. This is useful for both hand-crafted and JSON-like bindings.
%% This is the default lookup function used by rendering functions.
-spec lookup_var(accessor(), bindings()) ->
{ok, binding()} | {error, reason()}.
lookup_var(Var, Bindings) ->
lookup_var(0, Var, Bindings).
lookup_var(_, [], Value) ->
{ok, Value};
lookup_var(Loc, [Prop | Rest], Bindings) when is_map(Bindings) ->
case lookup(Prop, Bindings) of
{ok, Value} ->
lookup_var(Loc + 1, Rest, Value);
{error, Reason} ->
{error, Reason}
end;
lookup_var(Loc, _, Invalid) ->
{error, {Loc, type_name(Invalid)}}.
type_name(Term) when is_atom(Term) -> atom;
type_name(Term) when is_number(Term) -> number;
type_name(Term) when is_binary(Term) -> binary;
type_name(Term) when is_list(Term) -> list.
-spec lookup(Prop :: binary(), bindings()) ->
{ok, binding()} | {error, undefined}.
lookup(Prop, Bindings) when is_binary(Prop) ->
case maps:get(Prop, Bindings, undefined) of
undefined ->
try
{ok, maps:get(binary_to_existing_atom(Prop, utf8), Bindings)}
catch
error:{badkey, _} ->
{error, undefined};
error:badarg ->
{error, undefined}
end;
Value ->
{ok, Value}
end.
-spec to_string(binding()) ->
unicode:chardata().
to_string(Bin) when is_binary(Bin) -> Bin;
to_string(Num) when is_integer(Num) -> integer_to_binary(Num);
to_string(Num) when is_float(Num) -> float_to_binary(Num, [{decimals, 10}, compact]);
to_string(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
to_string(Map) when is_map(Map) -> emqx_utils_json:encode(Map);
to_string(List) when is_list(List) ->
case io_lib:printable_unicode_list(List) of
true -> List;
false -> emqx_utils_json:encode(List)
end.

View File

@ -0,0 +1,142 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2020-2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_template_sql).
-export([parse/1]).
-export([parse/2]).
-export([render/3]).
-export([render_strict/3]).
-export([parse_prepstmt/2]).
-export([render_prepstmt/2]).
-export([render_prepstmt_strict/2]).
-export_type([row_template/0]).
-type template() :: emqx_template:str().
-type row_template() :: [emqx_template:placeholder()].
-type context() :: emqx_template:context().
-type values() :: [emqx_utils_sql:value()].
-type parse_opts() :: #{
parameters => '$n' | ':n' | '?',
% Inherited from `emqx_template:parse_opts()`
strip_double_quote => boolean()
}.
-type render_opts() :: #{
%% String escaping rules to use.
%% Default: `sql` (generic)
escaping => sql | mysql | cql,
%% Value to map `undefined` to, either to NULLs or to arbitrary strings.
%% Default: `null`
undefined => null | unicode:chardata()
}.
-define(TEMPLATE_PARSE_OPTS, [strip_double_quote]).
%%
%% @doc Parse an SQL statement string with zero or more placeholders into a template.
-spec parse(unicode:chardata()) ->
template().
parse(String) ->
parse(String, #{}).
%% @doc Parse an SQL statement string with zero or more placeholders into a template.
-spec parse(unicode:chardata(), parse_opts()) ->
template().
parse(String, Opts) ->
emqx_template:parse(String, Opts).
%% @doc Render an SQL statement template given a set of bindings.
%% Interpolation generally follows the SQL syntax, strings are escaped according to the
%% `escaping` option.
-spec render(template(), context(), render_opts()) ->
{unicode:chardata(), [_Error]}.
render(Template, Context, Opts) ->
emqx_template:render(Template, Context, #{
var_trans => fun(Value) -> emqx_utils_sql:to_sql_string(Value, Opts) end
}).
%% @doc Render an SQL statement template given a set of bindings.
%% Errors are raised if any placeholders are not bound.
-spec render_strict(template(), context(), render_opts()) ->
unicode:chardata().
render_strict(Template, Context, Opts) ->
emqx_template:render_strict(Template, Context, #{
var_trans => fun(Value) -> emqx_utils_sql:to_sql_string(Value, Opts) end
}).
%% @doc Parse an SQL statement string into a prepared statement and a row template.
%% The row template is a template for a row of SQL values to be inserted to a database
%% during the execution of the prepared statement.
%% Example:
%% ```
%% {Statement, RowTemplate} = emqx_template_sql:parse_prepstmt(
%% "INSERT INTO table (id, name, age) VALUES (${id}, ${name}, 42)",
%% #{parameters => '$n'}
%% ),
%% Statement = <<"INSERT INTO table (id, name, age) VALUES ($1, $2, 42)">>,
%% RowTemplate = [{var, "...", [...]}, ...]
%% ```
-spec parse_prepstmt(unicode:chardata(), parse_opts()) ->
{unicode:chardata(), row_template()}.
parse_prepstmt(String, Opts) ->
Template = emqx_template:parse(String, maps:with(?TEMPLATE_PARSE_OPTS, Opts)),
Statement = mk_prepared_statement(Template, Opts),
Placeholders = [Placeholder || Placeholder <- Template, element(1, Placeholder) == var],
{Statement, Placeholders}.
mk_prepared_statement(Template, Opts) ->
ParameterFormat = maps:get(parameters, Opts, '?'),
{Statement, _} =
lists:mapfoldl(
fun
(Var, Acc) when element(1, Var) == var ->
mk_replace(ParameterFormat, Acc);
(String, Acc) ->
{String, Acc}
end,
1,
Template
),
Statement.
mk_replace('?', Acc) ->
{"?", Acc};
mk_replace('$n', N) ->
{"$" ++ integer_to_list(N), N + 1};
mk_replace(':n', N) ->
{":" ++ integer_to_list(N), N + 1}.
%% @doc Render a row template into a list of SQL values.
%% An _SQL value_ is a vaguely defined concept here, it is something that's considered
%% compatible with the protocol of the database being used. See the definition of
%% `emqx_utils_sql:value()` for more details.
-spec render_prepstmt(template(), context()) ->
{values(), [_Error]}.
render_prepstmt(Template, Context) ->
Opts = #{var_trans => fun emqx_utils_sql:to_sql_value/1},
emqx_template:render(Template, Context, Opts).
-spec render_prepstmt_strict(template(), context()) ->
values().
render_prepstmt_strict(Template, Context) ->
Opts = #{var_trans => fun emqx_utils_sql:to_sql_value/1},
emqx_template:render_strict(Template, Context, Opts).

View File

@ -80,10 +80,15 @@ to_sql_value(Map) when is_map(Map) -> emqx_utils_json:encode(Map).
%% @doc Convert an Erlang term to a string that can be interpolated in literal %% @doc Convert an Erlang term to a string that can be interpolated in literal
%% SQL statements. The value is escaped if necessary. %% SQL statements. The value is escaped if necessary.
-spec to_sql_string(term(), Options) -> iodata() when -spec to_sql_string(term(), Options) -> unicode:chardata() when
Options :: #{ Options :: #{
escaping => cql | mysql | sql escaping => mysql | sql | cql,
undefined => null | unicode:chardata()
}. }.
to_sql_string(undefined, #{undefined := Str} = Opts) when Str =/= null ->
to_sql_string(Str, Opts);
to_sql_string(undefined, #{}) ->
<<"NULL">>;
to_sql_string(String, #{escaping := mysql}) when is_binary(String) -> to_sql_string(String, #{escaping := mysql}) when is_binary(String) ->
try try
escape_mysql(String) escape_mysql(String)
@ -98,7 +103,7 @@ to_sql_string(Term, #{escaping := cql}) ->
to_sql_string(Term, #{}) -> to_sql_string(Term, #{}) ->
maybe_escape(Term, fun escape_sql/1). maybe_escape(Term, fun escape_sql/1).
-spec maybe_escape(_Value, fun((binary()) -> iodata())) -> iodata(). -spec maybe_escape(_Value, fun((binary()) -> iodata())) -> unicode:chardata().
maybe_escape(Str, EscapeFun) when is_binary(Str) -> maybe_escape(Str, EscapeFun) when is_binary(Str) ->
EscapeFun(Str); EscapeFun(Str);
maybe_escape(Str, EscapeFun) when is_list(Str) -> maybe_escape(Str, EscapeFun) when is_list(Str) ->
@ -109,9 +114,9 @@ maybe_escape(Str, EscapeFun) when is_list(Str) ->
error(Otherwise) error(Otherwise)
end; end;
maybe_escape(Val, EscapeFun) when is_atom(Val) orelse is_map(Val) -> maybe_escape(Val, EscapeFun) when is_atom(Val) orelse is_map(Val) ->
EscapeFun(emqx_utils_conv:bin(Val)); EscapeFun(emqx_template:to_string(Val));
maybe_escape(Val, _EscapeFun) -> maybe_escape(Val, _EscapeFun) ->
emqx_utils_conv:bin(Val). emqx_template:to_string(Val).
-spec escape_sql(binary()) -> iodata(). -spec escape_sql(binary()) -> iodata().
escape_sql(S) -> escape_sql(S) ->

View File

@ -0,0 +1,97 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2020-2022 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_jsonish_tests).
-include_lib("eunit/include/eunit.hrl").
prop_prio_test_() ->
[
?_assertEqual(
{ok, 42},
emqx_jsonish:lookup([<<"foo">>], #{<<"foo">> => 42, foo => 1337})
),
?_assertEqual(
{ok, 1337},
emqx_jsonish:lookup([<<"foo">>], #{foo => 1337})
)
].
undefined_test() ->
?assertEqual(
{error, undefined},
emqx_jsonish:lookup([<<"foo">>], #{})
).
undefined_deep_test() ->
?assertEqual(
{error, undefined},
emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{})
).
undefined_deep_json_test() ->
?assertEqual(
{error, undefined},
emqx_jsonish:lookup(
[<<"foo">>, <<"bar">>, <<"baz">>],
<<"{\"foo\":{\"bar\":{\"no\":{}}}}">>
)
).
invalid_type_test() ->
?assertEqual(
{error, {0, number}},
emqx_jsonish:lookup([<<"foo">>], <<"42">>)
).
invalid_type_deep_test() ->
?assertEqual(
{error, {2, atom}},
emqx_jsonish:lookup([<<"foo">>, <<"bar">>, <<"tuple">>], #{foo => #{bar => baz}})
).
decode_json_test() ->
?assertEqual(
{ok, 42},
emqx_jsonish:lookup([<<"foo">>, <<"bar">>], <<"{\"foo\":{\"bar\":42}}">>)
).
decode_json_deep_test() ->
?assertEqual(
{ok, 42},
emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{<<"foo">> => <<"{\"bar\": 42}">>})
).
decode_json_invalid_type_test() ->
?assertEqual(
{error, {1, list}},
emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{<<"foo">> => <<"[1,2,3]">>})
).
decode_no_json_test() ->
?assertEqual(
{error, {1, binary}},
emqx_jsonish:lookup([<<"foo">>, <<"bar">>], #{<<"foo">> => <<0, 1, 2, 3>>})
).
decode_json_no_nested_test() ->
?assertEqual(
{error, {2, binary}},
emqx_jsonish:lookup(
[<<"foo">>, <<"bar">>, <<"baz">>],
#{<<"foo">> => <<"{\"bar\":\"{\\\"baz\\\":42}\"}">>}
)
).

View File

@ -0,0 +1,360 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2020-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-module(emqx_template_SUITE).
-compile(export_all).
-compile(nowarn_export_all).
-include_lib("emqx/include/emqx_placeholder.hrl").
-include_lib("eunit/include/eunit.hrl").
all() -> emqx_common_test_helpers:all(?MODULE).
t_render(_) ->
Context = #{
a => <<"1">>,
b => 1,
c => 1.0,
d => #{<<"d1">> => <<"hi">>},
l => [0, 1, 1000],
u => "utf-8 is ǝɹǝɥ"
},
Template = emqx_template:parse(
<<"a:${a},b:${b},c:${c},d:${d},d1:${d.d1},l:${l},u:${u}">>
),
?assertEqual(
{<<"a:1,b:1,c:1.0,d:{\"d1\":\"hi\"},d1:hi,l:[0,1,1000],u:utf-8 is ǝɹǝɥ"/utf8>>, []},
render_string(Template, Context)
).
t_render_var_trans(_) ->
Context = #{a => <<"1">>, b => 1, c => #{prop => 1.0}},
Template = emqx_template:parse(<<"a:${a},b:${b},c:${c.prop}">>),
{String, Errors} = emqx_template:render(
Template,
Context,
#{var_trans => fun(Name, _) -> "<" ++ Name ++ ">" end}
),
?assertEqual(
{<<"a:<a>,b:<b>,c:<c.prop>">>, []},
{bin(String), Errors}
).
t_render_path(_) ->
Context = #{d => #{d1 => <<"hi">>}},
Template = emqx_template:parse(<<"d.d1:${d.d1}">>),
?assertEqual(
ok,
emqx_template:validate(["d.d1"], Template)
),
?assertEqual(
{<<"d.d1:hi">>, []},
render_string(Template, Context)
).
t_render_custom_ph(_) ->
Context = #{a => <<"a">>, b => <<"b">>},
Template = emqx_template:parse(<<"a:${a},b:${b}">>),
?assertEqual(
{error, [{"b", disallowed}]},
emqx_template:validate(["a"], Template)
),
?assertEqual(
<<"a:a,b:b">>,
render_strict_string(Template, Context)
).
t_render_this(_) ->
Context = #{a => <<"a">>, b => [1, 2, 3]},
Template = emqx_template:parse(<<"this:${} / also:${.}">>),
?assertEqual(ok, emqx_template:validate(["."], Template)),
?assertEqual(
% NOTE: order of the keys in the JSON object depends on the JSON encoder
<<"this:{\"b\":[1,2,3],\"a\":\"a\"} / also:{\"b\":[1,2,3],\"a\":\"a\"}">>,
render_strict_string(Template, Context)
).
t_render_missing_bindings(_) ->
Context = #{no => #{}, c => #{<<"c1">> => 42}},
Template = emqx_template:parse(
<<"a:${a},b:${b},c:${c.c1.c2},d:${d.d1},e:${no.such_atom_i_swear}">>
),
?assertEqual(
{<<"a:undefined,b:undefined,c:undefined,d:undefined,e:undefined">>, [
{"no.such_atom_i_swear", undefined},
{"d.d1", undefined},
{"c.c1.c2", {2, number}},
{"b", undefined},
{"a", undefined}
]},
render_string(Template, Context)
),
?assertError(
[
{"no.such_atom_i_swear", undefined},
{"d.d1", undefined},
{"c.c1.c2", {2, number}},
{"b", undefined},
{"a", undefined}
],
render_strict_string(Template, Context)
).
t_render_custom_bindings(_) ->
_ = erlang:put(a, <<"foo">>),
_ = erlang:put(b, #{<<"bar">> => #{atom => 42}}),
Template = emqx_template:parse(
<<"a:${a},b:${b.bar.atom},c:${c},oops:${b.bar.atom.oops}">>
),
?assertEqual(
{<<"a:foo,b:42,c:undefined,oops:undefined">>, [
{"b.bar.atom.oops", {2, number}},
{"c", undefined}
]},
render_string(Template, {?MODULE, []})
).
t_unparse(_) ->
TString = <<"a:${a},b:${b},c:$${c},d:{${d.d1}},e:${$}{e},lit:${$}{$}">>,
Template = emqx_template:parse(TString),
?assertEqual(
TString,
unicode:characters_to_binary(emqx_template:unparse(Template))
).
t_const(_) ->
?assertEqual(
true,
emqx_template:is_const(emqx_template:parse(<<"">>))
),
?assertEqual(
false,
emqx_template:is_const(
emqx_template:parse(<<"a:${a},b:${b},c:${$}{c}">>)
)
),
?assertEqual(
true,
emqx_template:is_const(
emqx_template:parse(<<"a:${$}{a},b:${$}{b}">>)
)
).
t_render_partial_ph(_) ->
Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
Template = emqx_template:parse(<<"a:$a,b:b},c:{c},d:${d">>),
?assertEqual(
<<"a:$a,b:b},c:{c},d:${d">>,
render_strict_string(Template, Context)
).
t_parse_escaped(_) ->
Context = #{a => <<"1">>, b => 1, c => "VAR"},
Template = emqx_template:parse(<<"a:${a},b:${$}{b},c:${$}{${c}},lit:${$}{$}">>),
?assertEqual(
<<"a:1,b:${b},c:${VAR},lit:${$}">>,
render_strict_string(Template, Context)
).
t_parse_escaped_dquote(_) ->
Context = #{a => <<"1">>, b => 1},
Template = emqx_template:parse(<<"a:\"${a}\",b:\"${$}{b}\"">>, #{
strip_double_quote => true
}),
?assertEqual(
<<"a:1,b:\"${b}\"">>,
render_strict_string(Template, Context)
).
t_parse_sql_prepstmt(_) ->
Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
{PrepareStatement, RowTemplate} =
emqx_template_sql:parse_prepstmt(<<"a:${a},b:${b},c:${c},d:${d}">>, #{
parameters => '?'
}),
?assertEqual(<<"a:?,b:?,c:?,d:?">>, bin(PrepareStatement)),
?assertEqual(
{[<<"1">>, 1, 1.0, <<"{\"d1\":\"hi\"}">>], _Errors = []},
emqx_template_sql:render_prepstmt(RowTemplate, Context)
).
t_parse_sql_prepstmt_n(_) ->
Context = #{a => undefined, b => true, c => atom, d => #{d1 => 42.1337}},
{PrepareStatement, RowTemplate} =
emqx_template_sql:parse_prepstmt(<<"a:${a},b:${b},c:${c},d:${d}">>, #{
parameters => '$n'
}),
?assertEqual(<<"a:$1,b:$2,c:$3,d:$4">>, bin(PrepareStatement)),
?assertEqual(
[null, true, <<"atom">>, <<"{\"d1\":42.1337}">>],
emqx_template_sql:render_prepstmt_strict(RowTemplate, Context)
).
t_parse_sql_prepstmt_colon(_) ->
{PrepareStatement, _RowTemplate} =
emqx_template_sql:parse_prepstmt(<<"a=${a},b=${b},c=${c},d=${d}">>, #{
parameters => ':n'
}),
?assertEqual(<<"a=:1,b=:2,c=:3,d=:4">>, bin(PrepareStatement)).
t_parse_sql_prepstmt_partial_ph(_) ->
Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
{PrepareStatement, RowTemplate} =
emqx_template_sql:parse_prepstmt(<<"a:$a,b:b},c:{c},d:${d">>, #{parameters => '?'}),
?assertEqual(<<"a:$a,b:b},c:{c},d:${d">>, bin(PrepareStatement)),
?assertEqual([], emqx_template_sql:render_prepstmt_strict(RowTemplate, Context)).
t_render_sql(_) ->
Context = #{
a => <<"1">>,
b => 1,
c => 1.0,
d => #{d1 => <<"hi">>},
n => undefined,
u => "utf8's cool 🐸"
},
Template = emqx_template:parse(<<"a:${a},b:${b},c:${c},d:${d},n:${n},u:${u}">>),
?assertMatch(
{_String, _Errors = []},
emqx_template_sql:render(Template, Context, #{})
),
?assertEqual(
<<"a:'1',b:1,c:1.0,d:'{\"d1\":\"hi\"}',n:NULL,u:'utf8\\'s cool 🐸'"/utf8>>,
bin(emqx_template_sql:render_strict(Template, Context, #{}))
),
?assertEqual(
<<"a:'1',b:1,c:1.0,d:'{\"d1\":\"hi\"}',n:'undefined',u:'utf8\\'s cool 🐸'"/utf8>>,
bin(emqx_template_sql:render_strict(Template, Context, #{undefined => "undefined"}))
).
t_render_mysql(_) ->
%% with apostrophes
%% https://github.com/emqx/emqx/issues/4135
Context = #{
a => <<"1''2">>,
b => 1,
c => 1.0,
d => #{d1 => <<"someone's phone">>},
e => <<$\\, 0, "💩"/utf8>>,
f => <<"non-utf8", 16#DCC900:24>>,
g => "utf8's cool 🐸",
h => imgood
},
Template = emqx_template_sql:parse(
<<"a:${a},b:${b},c:${c},d:${d},e:${e},f:${f},g:${g},h:${h}">>
),
?assertEqual(
<<
"a:'1\\'\\'2',b:1,c:1.0,d:'{\"d1\":\"someone\\'s phone\"}',"
"e:'\\\\\\0💩',f:0x6E6F6E2D75746638DCC900,g:'utf8\\'s cool 🐸',"/utf8,
"h:'imgood'"
>>,
bin(emqx_template_sql:render_strict(Template, Context, #{escaping => mysql}))
).
t_render_cql(_) ->
%% with apostrophes for cassandra
%% https://github.com/emqx/emqx/issues/4148
Context = #{
a => <<"1''2">>,
b => 1,
c => 1.0,
d => #{d1 => <<"someone's phone">>}
},
Template = emqx_template:parse(<<"a:${a},b:${b},c:${c},d:${d}">>),
?assertEqual(
<<"a:'1''''2',b:1,c:1.0,d:'{\"d1\":\"someone''s phone\"}'">>,
bin(emqx_template_sql:render_strict(Template, Context, #{escaping => cql}))
).
t_render_sql_custom_ph(_) ->
{PrepareStatement, RowTemplate} =
emqx_template_sql:parse_prepstmt(<<"a:${a},b:${b.c}">>, #{parameters => '$n'}),
?assertEqual(
{error, [{"b.c", disallowed}]},
emqx_template:validate(["a"], RowTemplate)
),
?assertEqual(<<"a:$1,b:$2">>, bin(PrepareStatement)).
t_render_sql_strip_double_quote(_) ->
Context = #{a => <<"a">>, b => <<"b">>},
%% no strip_double_quote option: "${key}" -> "value"
{PrepareStatement1, RowTemplate1} = emqx_template_sql:parse_prepstmt(
<<"a:\"${a}\",b:\"${b}\"">>,
#{parameters => '$n'}
),
?assertEqual(<<"a:\"$1\",b:\"$2\"">>, bin(PrepareStatement1)),
?assertEqual(
[<<"a">>, <<"b">>],
emqx_template_sql:render_prepstmt_strict(RowTemplate1, Context)
),
%% strip_double_quote = true: "${key}" -> value
{PrepareStatement2, RowTemplate2} = emqx_template_sql:parse_prepstmt(
<<"a:\"${a}\",b:\"${b}\"">>,
#{parameters => '$n', strip_double_quote => true}
),
?assertEqual(<<"a:$1,b:$2">>, bin(PrepareStatement2)),
?assertEqual(
[<<"a">>, <<"b">>],
emqx_template_sql:render_prepstmt_strict(RowTemplate2, Context)
).
t_render_tmpl_deep(_) ->
Context = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}},
Template = emqx_template:parse_deep(
#{<<"${a}">> => [<<"$${b}">>, "c", 2, 3.0, '${d}', {[<<"${c}">>, <<"${$}{d}">>], 0}]}
),
?assertEqual(
{error, [{V, disallowed} || V <- ["b", "c"]]},
emqx_template:validate(["a"], Template)
),
?assertEqual(
#{<<"1">> => [<<"$1">>, "c", 2, 3.0, '${d}', {[<<"1.0">>, <<"${d}">>], 0}]},
emqx_template:render_strict(Template, Context)
).
t_unparse_tmpl_deep(_) ->
Term = #{<<"${a}">> => [<<"$${b}">>, "c", 2, 3.0, '${d}', {[<<"${c}">>], <<"${$}{d}">>, 0}]},
Template = emqx_template:parse_deep(Term),
?assertEqual(Term, emqx_template:unparse(Template)).
%%
render_string(Template, Context) ->
{String, Errors} = emqx_template:render(Template, Context),
{bin(String), Errors}.
render_strict_string(Template, Context) ->
bin(emqx_template:render_strict(Template, Context)).
bin(String) ->
unicode:characters_to_binary(String).
%% Access module API
lookup([], _) ->
{error, undefined};
lookup([Prop | Rest], _) ->
case erlang:get(binary_to_atom(Prop)) of
undefined -> {error, undefined};
Value -> emqx_template:lookup_var(Rest, Value)
end.

View File

@ -338,6 +338,7 @@ defmodule EMQXUmbrella.MixProject do
:emqx_management, :emqx_management,
:emqx_retainer, :emqx_retainer,
:emqx_prometheus, :emqx_prometheus,
:emqx_rule_engine,
:emqx_auto_subscribe, :emqx_auto_subscribe,
:emqx_slow_subs, :emqx_slow_subs,
:emqx_plugins, :emqx_plugins,