diff --git a/apps/emqx_ldap/BSL.txt b/apps/emqx_ldap/BSL.txt
new file mode 100644
index 000000000..0acc0e696
--- /dev/null
+++ b/apps/emqx_ldap/BSL.txt
@@ -0,0 +1,94 @@
+Business Source License 1.1
+
+Licensor: Hangzhou EMQ Technologies Co., Ltd.
+Licensed Work: EMQX Enterprise Edition
+ The Licensed Work is (c) 2023
+ Hangzhou EMQ Technologies Co., Ltd.
+Additional Use Grant: Students and educators are granted right to copy,
+ modify, and create derivative work for research
+ or education.
+Change Date: 2027-02-01
+Change License: Apache License, Version 2.0
+
+For information about alternative licensing arrangements for the Software,
+please contact Licensor: https://www.emqx.com/en/contact
+
+Notice
+
+The Business Source License (this document, or the “License”) is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+“Business Source License” is a trademark of MariaDB Corporation Ab.
+
+-----------------------------------------------------------------------------
+
+Business Source License 1.1
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License’s text to license
+your works, and to refer to it using the trademark “Business Source License”,
+as long as you comply with the Covenants of Licensor below.
+
+Covenants of Licensor
+
+In consideration of the right to use this License’s text and the “Business
+Source License” name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+ or a license that is compatible with GPL Version 2.0 or a later version,
+ where “compatible” means that software provided under the Change License can
+ be included in a program with software provided under GPL Version 2.0 or a
+ later version. Licensor may specify additional Change Licenses without
+ limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+ impose any additional restriction on the right granted in this License, as
+ the Additional Use Grant; or (b) insert the text “None”.
+
+3. To specify a Change Date.
+
+4. Not to modify this License in any other way.
diff --git a/apps/emqx_ldap/README.md b/apps/emqx_ldap/README.md
new file mode 100644
index 000000000..5923a10d7
--- /dev/null
+++ b/apps/emqx_ldap/README.md
@@ -0,0 +1,14 @@
+# LDAP Connector
+
+This application houses the LDAP connector.
+It provides the APIs to connect to the LDAP service.
+
+It is used by the emqx_authz and emqx_authn applications to check user permissions.
+
+## Contributing
+
+Please see our [contributing.md](../../CONTRIBUTING.md).
+
+## License
+
+See [APL](../../APL.txt).
diff --git a/apps/emqx_ldap/docker-ct b/apps/emqx_ldap/docker-ct
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/apps/emqx_ldap/docker-ct
@@ -0,0 +1 @@
+
diff --git a/apps/emqx_ldap/rebar.config b/apps/emqx_ldap/rebar.config
new file mode 100644
index 000000000..e6e2db243
--- /dev/null
+++ b/apps/emqx_ldap/rebar.config
@@ -0,0 +1,7 @@
+%% -*- mode: erlang; -*-
+
+{erl_opts, [debug_info]}.
+{deps, [
+ {emqx_connector, {path, "../../apps/emqx_connector"}},
+ {emqx_resource, {path, "../../apps/emqx_resource"}}
+]}.
diff --git a/apps/emqx_ldap/src/emqx_ldap.app.src b/apps/emqx_ldap/src/emqx_ldap.app.src
new file mode 100644
index 000000000..976d85371
--- /dev/null
+++ b/apps/emqx_ldap/src/emqx_ldap.app.src
@@ -0,0 +1,13 @@
+{application, emqx_ldap, [
+ {description, "EMQX LDAP Connector"},
+ {vsn, "0.1.0"},
+ {registered, []},
+ {applications, [
+ kernel,
+ stdlib
+ ]},
+ {env, []},
+ {modules, []},
+
+ {links, []}
+]}.
diff --git a/apps/emqx_ldap/src/emqx_ldap.erl b/apps/emqx_ldap/src/emqx_ldap.erl
new file mode 100644
index 000000000..4b43bb6f6
--- /dev/null
+++ b/apps/emqx_ldap/src/emqx_ldap.erl
@@ -0,0 +1,230 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+-module(emqx_ldap).
+
+-include_lib("emqx_connector/include/emqx_connector.hrl").
+-include_lib("typerefl/include/types.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("emqx/include/logger.hrl").
+-include_lib("snabbkaffe/include/snabbkaffe.hrl").
+-include_lib("eldap/include/eldap.hrl").
+
+-behaviour(emqx_resource).
+
+%% callbacks of behaviour emqx_resource
+-export([
+ callback_mode/0,
+ on_start/2,
+ on_stop/2,
+ on_query/3,
+ on_get_status/2
+]).
+
+%% ecpool connect & reconnect
+-export([connect/1]).
+
+-export([roots/0, fields/1]).
+
+-export([do_get_status/1]).
+
+-define(LDAP_HOST_OPTIONS, #{
+ default_port => 389
+}).
+
+-type params_tokens() :: #{atom() => list()}.
+-type state() ::
+ #{
+ pool_name := binary(),
+ base_tokens := params_tokens(),
+ filter_tokens := params_tokens()
+ }.
+
+-define(ECS, emqx_connector_schema_lib).
+
+%%=====================================================================
+%% Hocon schema
+roots() ->
+ [{config, #{type => hoconsc:ref(?MODULE, config)}}].
+
+fields(config) ->
+ [
+ {server, server()},
+ {pool_size, fun ?ECS:pool_size/1},
+ {username, fun ensure_username/1},
+ {password, fun ?ECS:password/1},
+ {base_object,
+ ?HOCON(binary(), #{
+ desc => ?DESC(base_object),
+ required => true
+ })},
+ {filter, ?HOCON(binary(), #{desc => ?DESC(filter), default => ""})},
+ {auto_reconnect, fun ?ECS:auto_reconnect/1}
+ ] ++ emqx_connector_schema_lib:ssl_fields().
+
+server() ->
+ Meta = #{desc => ?DESC("server")},
+ emqx_schema:servers_sc(Meta, ?LDAP_HOST_OPTIONS).
+
+ensure_username(required) ->
+ true;
+ensure_username(Field) ->
+ ?ECS:username(Field).
+
+%% ===================================================================
+callback_mode() -> always_sync.
+
+-spec on_start(binary(), hoconsc:config()) -> {ok, state()} | {error, _}.
+on_start(
+ InstId,
+ #{
+ server := Server,
+ pool_size := PoolSize,
+ ssl := SSL
+ } = Config
+) ->
+ HostPort = emqx_schema:parse_server(Server, ?LDAP_HOST_OPTIONS),
+ ?SLOG(info, #{
+ msg => "starting_ldap_connector",
+ connector => InstId,
+ config => emqx_utils:redact(Config)
+ }),
+
+ Config2 = maps:merge(Config, HostPort),
+ Config3 =
+ case maps:get(enable, SSL) of
+ true ->
+ Config2#{sslopts => emqx_tls_lib:to_client_opts(SSL)};
+ false ->
+ Config2
+ end,
+ Options = [
+ {pool_size, PoolSize},
+ {auto_reconnect, ?AUTO_RECONNECT_INTERVAL},
+ {options, Config3}
+ ],
+
+ case emqx_resource_pool:start(InstId, ?MODULE, Options) of
+ ok ->
+ {ok, prepare_template(Config, #{pool_name => InstId})};
+ {error, Reason} ->
+ ?tp(
+ ldap_connector_start_failed,
+ #{error => Reason}
+ ),
+ {error, Reason}
+ end.
+
+on_stop(InstId, _State) ->
+ ?SLOG(info, #{
+ msg => "stopping_ldap_connector",
+ connector => InstId
+ }),
+ emqx_resource_pool:stop(InstId).
+
+on_query(InstId, {query, Data}, State) ->
+ on_query(InstId, {query, Data}, [], State);
+on_query(InstId, {query, Data, Attrs}, State) ->
+ on_query(InstId, {query, Data}, [{attributes, Attrs}], State);
+on_query(InstId, {query, Data, Attrs, Timeout}, State) ->
+ on_query(InstId, {query, Data}, [{attributes, Attrs}, {timeout, Timeout}], State).
+
+on_get_status(_InstId, #{pool_name := PoolName} = _State) ->
+ case emqx_resource_pool:health_check_workers(PoolName, fun ?MODULE:do_get_status/1) of
+ true ->
+ connected;
+ false ->
+ connecting
+ end.
+
+do_get_status(Conn) ->
+ erlang:is_process_alive(Conn).
+
+%% ===================================================================
+
+connect(Options) ->
+ #{host := Host, username := Username, password := Password} =
+ Conf = proplists:get_value(options, Options),
+ OpenOpts = maps:to_list(maps:with([port, sslopts], Conf)),
+ case eldap:open([Host], [{log, fun log/3}, OpenOpts]) of
+ {ok, Handle} = Ret ->
+ case eldap:simple_bind(Handle, Username, Password) of
+ ok -> Ret;
+ Error -> Error
+ end;
+ Error ->
+ Error
+ end.
+
+on_query(
+ InstId,
+ {query, Data},
+ SearchOptions,
+ #{base_tokens := BaseTks, filter_tokens := FilterTks} = State
+) ->
+ Base = emqx_placeholder:proc_tmpl(BaseTks, Data),
+ case FilterTks of
+ [] ->
+ do_ldap_query(InstId, [{base, Base} | SearchOptions], State);
+ _ ->
+ FilterBin = emqx_placeholder:proc_tmpl(FilterTks, Data),
+ %% TODO
+ Filter = FilterBin,
+ do_ldap_query(
+ InstId,
+ [{base, Base}, {filter, Filter} | SearchOptions],
+ State
+ )
+ end.
+
+do_ldap_query(
+ InstId,
+ SearchOptions,
+ #{pool_name := PoolName} = State
+) ->
+ LogMeta = #{connector => InstId, search => SearchOptions, state => State},
+ ?TRACE("QUERY", "ldap_connector_received", LogMeta),
+ case
+ ecpool:pick_and_do(
+ PoolName,
+ {eldap, search, SearchOptions},
+ handover
+ )
+ of
+ {ok, Result} ->
+ ?tp(
+ ldap_connector_query_return,
+ #{result => Result}
+ ),
+ {ok,
+ case Result#eldap_search_result.entries of
+ [First | _] ->
+ %% TODO Support multi entries?
+ First;
+ _ ->
+ undefined
+ end};
+ {error, Reason} ->
+ ?SLOG(
+ error,
+ LogMeta#{msg => "ldap_connector_do_sql_query_failed", reason => Reason}
+ ),
+ {error, {unrecoverable_error, Reason}}
+ end.
+
+log(Level, Format, Args) ->
+ ?SLOG(
+ Level,
+ #{
+ msg => "ldap_log",
+ log => io_lib:format(Format, Args)
+ }
+ ).
+
+prepare_template(Config, State) ->
+ do_prepare_template(maps:to_list(maps:with([base_object, filter], Config)), State).
+
+do_prepare_template([{base_object, V} | T], State) ->
+ do_prepare_template(T, State#{base_tokens => emqx_placeholder:preproc_tmpl(V)});
+do_prepare_template([{filter, V} | T], State) ->
+ do_prepare_template(T, State#{filter_tokens => emqx_placeholder:preproc_tmpl(V)}).
diff --git a/apps/emqx_ldap/src/emqx_ldap_filter_lexer.xrl b/apps/emqx_ldap/src/emqx_ldap_filter_lexer.xrl
new file mode 100644
index 000000000..ae5c0cdbf
--- /dev/null
+++ b/apps/emqx_ldap/src/emqx_ldap_filter_lexer.xrl
@@ -0,0 +1,24 @@
+Definitions.
+
+NonControl = [^()&|!=~><:*]
+String = {NonControl}*
+White = [\s\t\n\r]+
+
+Rules.
+
+\( : {token, {lparen, TokenLine}}.
+\) : {token, {rparen, TokenLine}}.
+\& : {token, {'and', TokenLine}}.
+\| : {token, {'or', TokenLine}}.
+\! : {token, {'not', TokenLine}}.
+= : {token, {equal, TokenLine}}.
+~= : {token, {approx, TokenLine}}.
+>= : {token, {greaterOrEqual, TokenLine}}.
+<= : {token, {lessOrEqual, TokenLine}}.
+\* : {token, {asterisk, TokenLine}}.
+\: : {token, {colon, TokenLine}}.
+dn : {token, {dn, TokenLine}}.
+{White} : skip_token.
+{String} : {token, {string, TokenLine, TokenChars}}.
+
+Erlang code.
diff --git a/apps/emqx_ldap/src/emqx_ldap_filter_parser.yrl b/apps/emqx_ldap/src/emqx_ldap_filter_parser.yrl
new file mode 100644
index 000000000..2e9cf193d
--- /dev/null
+++ b/apps/emqx_ldap/src/emqx_ldap_filter_parser.yrl
@@ -0,0 +1,133 @@
+Nonterminals
+filter filtercomp filterlist item simple present substring initial any final extensible attr value type dnattrs matchingrule.
+
+Terminals
+lparen rparen 'and' 'or' 'not' equal approx greaterOrEqual lessOrEqual asterisk colon dn string.
+
+Rootsymbol filter.
+Left 100 present.
+Left 500 substring.
+
+filter ->
+ lparen filtercomp rparen : '$2'.
+
+filtercomp ->
+ 'and' filterlist: 'and'('$2').
+filtercomp ->
+ 'or' filterlist: 'or'('$2').
+filtercomp ->
+ 'not' filterlist: 'not'('$2').
+filtercomp ->
+ item: '$1'.
+
+filterlist ->
+ filter: '$1'.
+filterlist ->
+ filter filterlist: ['$1' | '$2'].
+
+item ->
+ simple: '$1'.
+item ->
+ present: '$1'.
+item ->
+ substring: '$1'.
+item->
+ extensible: '$1'.
+
+simple ->
+ attr equal value: equal('$1', '$3').
+simple ->
+ attr approx value: approx('$1', '$3').
+simple ->
+ attr greaterOrEqual value: greaterOrEqual('$1', '$3').
+simple ->
+ attr lessOrEqual value: lessOrEqual('$1', '$3').
+
+present ->
+ attr equal asterisk: present('$1').
+
+substring ->
+ attr equal initial asterisk any final: substrings('$1', ['$3', '$5', '$6']).
+substring ->
+ attr equal asterisk any final: substrings('$1', ['$4', '$5']).
+substring ->
+ attr equal initial asterisk any: substrings('$1', ['$3', '$5']).
+substring ->
+ attr equal asterisk any: substrings('$1', ['$4']).
+
+initial ->
+ value: {initial, '$1'}.
+
+final ->
+ value: {final, '$1'}.
+
+any -> any value asterisk: 'any'('$1', '$2').
+any -> '$empty': [].
+
+extensible ->
+ type dnattrs matchingrule colon equal value : extensible('$6', ['$1', '$2', '$3']).
+extensible ->
+ type dnattrs colon equal value: extensible('$5', ['$1', '$2']).
+extensible ->
+ type matchingrule colon equal value: extensible('$5', ['$1', '$2']).
+extensible ->
+ type colon equal value: extensible('$4', []).
+
+extensible ->
+ dnattrs matchingrule colon equal value: extensible('$5', ['$1', '$2']).
+extensible ->
+ matchingrule colon equal value: extensible('$4', ['$1']).
+
+attr ->
+ string: get_value('$1').
+
+value ->
+ string: get_value('$1').
+
+type ->
+ value: {type, '$1'}.
+
+dnattrs ->
+ colon dn: {dnAttributes, true}.
+
+matchingrule ->
+ colon value: {matchingRule, '$2'}.
+
+Erlang code.
+
+'and'(Value) ->
+ eldap:'and'(Value).
+
+'or'(Value) ->
+ eldap:'or'(Value).
+
+'not'(Value) ->
+ eldap:'not'(Value).
+
+equal(Attr, Value) ->
+ eldap:equalityMatch(Attr, Value).
+
+approx(Attr, Value) ->
+ eldap:approxMatch(Attr, Value).
+
+greaterOrEqual(Attr, Value) ->
+ eldap:greaterOrEqual(Attr, Value).
+
+lessOrEqual(Attr, Value) ->
+ eldap:lessOrEqual(Attr, Value).
+
+present(Value) ->
+ eldap:present(Value).
+
+substrings(Attr, List) ->
+ eldap:substrings(Attr, flatten(List)).
+
+'any'(List, Item) ->
+ [{any, Item} | List].
+
+extensible(Value, Opts) -> eldap:extensibleMatch(Value, Opts).
+
+flatten(List) -> lists:flatten(List).
+
+get_value({_Token, _Line, Value}) ->
+ Value.
diff --git a/apps/emqx_ldap/test/emqx_ldap_SUITE.erl b/apps/emqx_ldap/test/emqx_ldap_SUITE.erl
new file mode 100644
index 000000000..515f8b250
--- /dev/null
+++ b/apps/emqx_ldap/test/emqx_ldap_SUITE.erl
@@ -0,0 +1,165 @@
+% %%--------------------------------------------------------------------
+% %% 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_ldap_SUITE).
+
+-compile(nowarn_export_all).
+-compile(export_all).
+
+-include_lib("emqx_connector/include/emqx_connector.hrl").
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("emqx/include/emqx.hrl").
+-include_lib("stdlib/include/assert.hrl").
+
+-define(MYSQL_HOST, "ldap").
+-define(MYSQL_RESOURCE_MOD, emqx_ldap).
+
+all() ->
+ emqx_common_test_helpers:all(?MODULE).
+
+groups() ->
+ [].
+
+init_per_suite(Config) ->
+ case emqx_common_test_helpers:is_tcp_server_available(?MYSQL_HOST, ?MYSQL_DEFAULT_PORT) of
+ true ->
+ ok = emqx_common_test_helpers:start_apps([emqx_conf]),
+ ok = emqx_connector_test_helpers:start_apps([emqx_resource]),
+ {ok, _} = application:ensure_all_started(emqx_connector),
+ Config;
+ false ->
+ {skip, no_ldap}
+ end.
+
+end_per_suite(_Config) ->
+ ok = emqx_common_test_helpers:stop_apps([emqx_conf]),
+ ok = emqx_connector_test_helpers:stop_apps([emqx_resource]),
+ _ = application:stop(emqx_connector).
+
+init_per_testcase(_, Config) ->
+ Config.
+
+end_per_testcase(_, _Config) ->
+ ok.
+
+% %%------------------------------------------------------------------------------
+% %% Testcases
+% %%------------------------------------------------------------------------------
+
+t_lifecycle(_Config) ->
+ perform_lifecycle_check(
+ <<"emqx_ldap_SUITE">>,
+ ldap_config()
+ ).
+
+perform_lifecycle_check(ResourceId, InitialConfig) ->
+ {ok, #{config := CheckedConfig}} =
+ emqx_resource:check_config(?MYSQL_RESOURCE_MOD, InitialConfig),
+ {ok, #{
+ state := #{pool_name := PoolName} = State,
+ status := InitialStatus
+ }} = emqx_resource:create_local(
+ ResourceId,
+ ?CONNECTOR_RESOURCE_GROUP,
+ ?MYSQL_RESOURCE_MOD,
+ CheckedConfig,
+ #{}
+ ),
+ ?assertEqual(InitialStatus, connected),
+ % Instance should match the state and status of the just started resource
+ {ok, ?CONNECTOR_RESOURCE_GROUP, #{
+ state := State,
+ status := InitialStatus
+ }} =
+ emqx_resource:get_instance(ResourceId),
+ ?assertEqual({ok, connected}, emqx_resource:health_check(ResourceId)),
+ % % Perform query as further check that the resource is working as expected
+ ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_no_params())),
+ ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_with_params())),
+ ?assertMatch(
+ {ok, _, [[1]]},
+ emqx_resource:query(
+ ResourceId,
+ test_query_with_params_and_timeout()
+ )
+ ),
+ ?assertEqual(ok, emqx_resource:stop(ResourceId)),
+ % Resource will be listed still, but state will be changed and healthcheck will fail
+ % as the worker no longer exists.
+ {ok, ?CONNECTOR_RESOURCE_GROUP, #{
+ state := State,
+ status := StoppedStatus
+ }} =
+ emqx_resource:get_instance(ResourceId),
+ ?assertEqual(stopped, StoppedStatus),
+ ?assertEqual({error, resource_is_stopped}, emqx_resource:health_check(ResourceId)),
+ % Resource healthcheck shortcuts things by checking ets. Go deeper by checking pool itself.
+ ?assertEqual({error, not_found}, ecpool:stop_sup_pool(PoolName)),
+ % Can call stop/1 again on an already stopped instance
+ ?assertEqual(ok, emqx_resource:stop(ResourceId)),
+ % Make sure it can be restarted and the healthchecks and queries work properly
+ ?assertEqual(ok, emqx_resource:restart(ResourceId)),
+ % async restart, need to wait resource
+ timer:sleep(500),
+ {ok, ?CONNECTOR_RESOURCE_GROUP, #{status := InitialStatus}} =
+ emqx_resource:get_instance(ResourceId),
+ ?assertEqual({ok, connected}, emqx_resource:health_check(ResourceId)),
+ ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_no_params())),
+ ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_with_params())),
+ ?assertMatch(
+ {ok, _, [[1]]},
+ emqx_resource:query(
+ ResourceId,
+ test_query_with_params_and_timeout()
+ )
+ ),
+ % Stop and remove the resource in one go.
+ ?assertEqual(ok, emqx_resource:remove_local(ResourceId)),
+ ?assertEqual({error, not_found}, ecpool:stop_sup_pool(PoolName)),
+ % Should not even be able to get the resource data out of ets now unlike just stopping.
+ ?assertEqual({error, not_found}, emqx_resource:get_instance(ResourceId)).
+
+% %%------------------------------------------------------------------------------
+% %% Helpers
+% %%------------------------------------------------------------------------------
+
+ldap_config() ->
+ RawConfig = list_to_binary(
+ io_lib:format(
+ ""
+ "\n"
+ " auto_reconnect = true\n"
+ " database = mqtt\n"
+ " username= root\n"
+ " password = public\n"
+ " pool_size = 8\n"
+ " server = \"~s:~b\"\n"
+ " "
+ "",
+ [?MYSQL_HOST, ?MYSQL_DEFAULT_PORT]
+ )
+ ),
+
+ {ok, Config} = hocon:binary(RawConfig),
+ #{<<"config">> => Config}.
+
+test_query_no_params() ->
+ {sql, <<"SELECT 1">>}.
+
+test_query_with_params() ->
+ {sql, <<"SELECT ?">>, [1]}.
+
+test_query_with_params_and_timeout() ->
+ {sql, <<"SELECT ?">>, [1], 1000}.
diff --git a/rel/i18n/emqx_ldap.hocon b/rel/i18n/emqx_ldap.hocon
new file mode 100644
index 000000000..9f3d9216b
--- /dev/null
+++ b/rel/i18n/emqx_ldap.hocon
@@ -0,0 +1,25 @@
+emqx_ldap {
+
+server.desc:
+"""The IPv4 or IPv6 address or the hostname to connect to.
+A host entry has the following form: `Host[:Port]`.
+The MySQL default port 3306 is used if `[:Port]` is not specified."""
+
+server.label:
+"""Server Host"""
+
+base_object.desc:
+"""The name of the base object entry (or possibly the root) relative to
+which the Search is to be performed."""
+
+base_object.label:
+"""Base Object"""
+
+filter.desc:
+"""The filter that defines the conditions that must be fulfilled in order
+for the Search to match a given entry."""
+
+filter.label:
+"""Filter"""
+
+}