Forráskód Böngészése

Merge pull request #11379 from lafirest/feat/ldap

feat(ldap): add LDAP connector
lafirest 2 éve
szülő
commit
22b4f4d256

+ 4 - 4
.ci/docker-compose-file/docker-compose-ldap-tcp.yaml

@@ -6,11 +6,11 @@ services:
     build:
     build:
       context: ../..
       context: ../..
       dockerfile: .ci/docker-compose-file/openldap/Dockerfile
       dockerfile: .ci/docker-compose-file/openldap/Dockerfile
-      args: 
+      args:
         LDAP_TAG: ${LDAP_TAG}
         LDAP_TAG: ${LDAP_TAG}
-    image: openldap 
-    ports:
-      - 389:389
+    image: openldap
+    #ports:
+    #  - 389:389
     restart: always
     restart: always
     networks:
     networks:
       - emqx_bridge
       - emqx_bridge

+ 9 - 7
.ci/docker-compose-file/openldap/Dockerfile

@@ -1,18 +1,20 @@
-FROM buildpack-deps:stretch
+FROM buildpack-deps:bookworm
 
 
-ARG LDAP_TAG=2.4.50
+ARG LDAP_TAG=2.5.16
 
 
 RUN apt-get update && apt-get install -y groff groff-base
 RUN apt-get update && apt-get install -y groff groff-base
-RUN wget ftp://ftp.openldap.org/pub/OpenLDAP/openldap-release/openldap-${LDAP_TAG}.tgz \
-    && gunzip -c openldap-${LDAP_TAG}.tgz | tar xvfB - \
+RUN wget https://www.openldap.org/software/download/OpenLDAP/openldap-release/openldap-${LDAP_TAG}.tgz \
+    && tar xvzf openldap-${LDAP_TAG}.tgz \
     && cd openldap-${LDAP_TAG} \
     && cd openldap-${LDAP_TAG} \
     && ./configure && make depend && make && make install \
     && ./configure && make depend && make && make install \
     && cd .. && rm -rf  openldap-${LDAP_TAG}
     && cd .. && rm -rf  openldap-${LDAP_TAG}
 
 
 COPY .ci/docker-compose-file/openldap/slapd.conf /usr/local/etc/openldap/slapd.conf
 COPY .ci/docker-compose-file/openldap/slapd.conf /usr/local/etc/openldap/slapd.conf
-COPY apps/emqx_authn/test/data/emqx.io.ldif /usr/local/etc/openldap/schema/emqx.io.ldif
-COPY apps/emqx_authn/test/data/emqx.schema /usr/local/etc/openldap/schema/emqx.schema
-COPY apps/emqx_authn/test/data/certs/*.pem /usr/local/etc/openldap/
+COPY apps/emqx_ldap/test/data/emqx.io.ldif /usr/local/etc/openldap/schema/emqx.io.ldif
+COPY apps/emqx_ldap/test/data/emqx.schema /usr/local/etc/openldap/schema/emqx.schema
+COPY .ci/docker-compose-file/certs/ca.crt /usr/local/etc/openldap/cacert.pem
+COPY .ci/docker-compose-file/certs/server.crt /usr/local/etc/openldap/cert.pem
+COPY .ci/docker-compose-file/certs/server.key /usr/local/etc/openldap/key.pem
 
 
 RUN mkdir -p /usr/local/etc/openldap/data \
 RUN mkdir -p /usr/local/etc/openldap/data \
     && slapadd -l /usr/local/etc/openldap/schema/emqx.io.ldif -f /usr/local/etc/openldap/slapd.conf
     && slapadd -l /usr/local/etc/openldap/schema/emqx.io.ldif -f /usr/local/etc/openldap/slapd.conf

+ 2 - 0
apps/emqx_ldap/.gitignore

@@ -0,0 +1,2 @@
+src/emqx_ldap_filter_lexer.erl
+src/emqx_ldap_filter_parser.erl

+ 94 - 0
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.

+ 14 - 0
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).

+ 1 - 0
apps/emqx_ldap/docker-ct

@@ -0,0 +1 @@
+ldap

+ 7 - 0
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"}}
+]}.

+ 13 - 0
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, []}
+]}.

+ 237 - 0
apps/emqx_ldap/src/emqx_ldap.erl

@@ -0,0 +1,237 @@
+%%--------------------------------------------------------------------
+%% 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 => <<"(objectClass=mqttUser)">>})},
+        {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) ->
+    #{hostname := 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),
+            case emqx_ldap_filter_parser:scan_and_parse(FilterBin) of
+                {ok, Filter} ->
+                    do_ldap_query(
+                        InstId,
+                        [{base, Base}, {filter, Filter} | SearchOptions],
+                        State
+                    );
+                {error, Reason} = Error ->
+                    ?SLOG(error, #{
+                        msg => "filter_parse_failed",
+                        filter => FilterBin,
+                        reason => Reason
+                    }),
+                    Error
+            end
+    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, Result#eldap_search_result.entries};
+        {error, 'noSuchObject'} ->
+            {ok, []};
+        {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)});
+do_prepare_template([], State) ->
+    State.

+ 31 - 0
apps/emqx_ldap/src/emqx_ldap_filter_lexer.xrl

@@ -0,0 +1,31 @@
+Definitions.
+
+Control = [()&|!=~><:*]
+White = [\s\t\n\r]+
+NonString = [^()&|!=~><:*\s\t\n\r]
+String = {NonString}+
+
+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}}.
+%% Leex will hang if a composite operation is missing a character
+{Control} : {error, lists:flatten(io_lib:format("Unexpected Tokens:~ts", [TokenChars]))}.
+
+Erlang code.
+
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------

+ 149 - 0
apps/emqx_ldap/src/emqx_ldap_filter_parser.yrl

@@ -0,0 +1,149 @@
+Header "%%--------------------------------------------------------------------
+%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------".
+
+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' filter: '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', ['$1']).
+
+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.
+-export([scan_and_parse/1]).
+-ignore_xref({return_error, 2}).
+
+'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) ->
+    [List, {any, Item}].
+
+extensible(Value, Opts) -> eldap:extensibleMatch(Value, Opts).
+
+flatten(List) -> lists:flatten(List).
+
+get_value({_Token, _Line, Value}) ->
+    Value.
+
+scan_and_parse(Bin) when is_binary(Bin) ->
+    scan_and_parse(erlang:binary_to_list(Bin));
+scan_and_parse(String) ->
+    case emqx_ldap_filter_lexer:string(String) of
+        {ok, Tokens, _} ->
+            parse(Tokens);
+        {error, Reason, _} ->
+            {error, Reason}
+    end.

+ 135 - 0
apps/emqx_ldap/test/data/emqx.io.ldif

@@ -0,0 +1,135 @@
+## create emqx.io
+
+dn:dc=emqx,dc=io
+objectclass: top
+objectclass: dcobject
+objectclass: organization
+dc:emqx
+o:emqx,Inc.
+
+# create testdevice.emqx.io
+dn:ou=testdevice,dc=emqx,dc=io
+objectClass: top
+objectclass:organizationalUnit
+ou:testdevice
+
+# create user admin
+dn:uid=admin,ou=testdevice,dc=emqx,dc=io
+objectClass: top
+objectClass: simpleSecurityObject
+objectClass: account
+userPassword:: e1NIQX1XNnBoNU1tNVB6OEdnaVVMYlBnekczN21qOWc9
+uid: admin
+
+## create user=mqttuser0001,
+#         password=mqttuser0001,
+#         passhash={SHA}mlb3fat40MKBTXUVZwCKmL73R/0=
+#         base64passhash=e1NIQX1tbGIzZmF0NDBNS0JUWFVWWndDS21MNzNSLzA9
+dn:uid=mqttuser0001,ou=testdevice,dc=emqx,dc=io
+objectClass: top
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0001
+isEnabled: TRUE
+mqttAccountName: user1
+mqttPublishTopic: mqttuser0001/pub/1
+mqttPublishTopic: mqttuser0001/pub/+
+mqttPublishTopic: mqttuser0001/pub/#
+mqttSubscriptionTopic: mqttuser0001/sub/1
+mqttSubscriptionTopic: mqttuser0001/sub/+
+mqttSubscriptionTopic: mqttuser0001/sub/#
+mqttPubSubTopic: mqttuser0001/pubsub/1
+mqttPubSubTopic: mqttuser0001/pubsub/+
+mqttPubSubTopic: mqttuser0001/pubsub/#
+userPassword:: e1NIQX1tbGIzZmF0NDBNS0JUWFVWWndDS21MNzNSLzA9
+
+## create user=mqttuser0002
+#         password=mqttuser0002,
+#         passhash={SSHA}n9XdtoG4Q/TQ3TQF4Y+khJbMBH4qXj4M
+#         base64passhash=e1NTSEF9bjlYZHRvRzRRL1RRM1RRRjRZK2toSmJNQkg0cVhqNE0=
+dn:uid=mqttuser0002,ou=testdevice,dc=emqx,dc=io
+objectClass: top
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0002
+isEnabled: TRUE
+mqttAccountName: user2
+mqttPublishTopic: mqttuser0002/pub/1
+mqttPublishTopic: mqttuser0002/pub/+
+mqttPublishTopic: mqttuser0002/pub/#
+mqttSubscriptionTopic: mqttuser0002/sub/1
+mqttSubscriptionTopic: mqttuser0002/sub/+
+mqttSubscriptionTopic: mqttuser0002/sub/#
+mqttPubSubTopic: mqttuser0002/pubsub/1
+mqttPubSubTopic: mqttuser0002/pubsub/+
+mqttPubSubTopic: mqttuser0002/pubsub/#
+userPassword:: e1NTSEF9bjlYZHRvRzRRL1RRM1RRRjRZK2toSmJNQkg0cVhqNE0=
+
+## create user mqttuser0003
+#         password=mqttuser0003,
+#         passhash={MD5}ybsPGoaK3nDyiQvveiCOIw==
+#         base64passhash=e01ENX15YnNQR29hSzNuRHlpUXZ2ZWlDT0l3PT0=
+dn:uid=mqttuser0003,ou=testdevice,dc=emqx,dc=io
+objectClass: top
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0003
+isEnabled: TRUE
+mqttPublishTopic: mqttuser0003/pub/1
+mqttPublishTopic: mqttuser0003/pub/+
+mqttPublishTopic: mqttuser0003/pub/#
+mqttSubscriptionTopic: mqttuser0003/sub/1
+mqttSubscriptionTopic: mqttuser0003/sub/+
+mqttSubscriptionTopic: mqttuser0003/sub/#
+mqttPubSubTopic: mqttuser0003/pubsub/1
+mqttPubSubTopic: mqttuser0003/pubsub/+
+mqttPubSubTopic: mqttuser0003/pubsub/#
+userPassword:: e01ENX15YnNQR29hSzNuRHlpUXZ2ZWlDT0l3PT0=
+
+## create user mqttuser0004
+#         password=mqttuser0004,
+#         passhash={MD5}2Br6pPDSEDIEvUlu9+s+MA==
+#         base64passhash=e01ENX0yQnI2cFBEU0VESUV2VWx1OStzK01BPT0=
+dn:uid=mqttuser0004,ou=testdevice,dc=emqx,dc=io
+objectClass: top
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0004
+isEnabled: TRUE
+mqttPublishTopic: mqttuser0004/pub/1
+mqttPublishTopic: mqttuser0004/pub/+
+mqttPublishTopic: mqttuser0004/pub/#
+mqttSubscriptionTopic: mqttuser0004/sub/1
+mqttSubscriptionTopic: mqttuser0004/sub/+
+mqttSubscriptionTopic: mqttuser0004/sub/#
+mqttPubSubTopic: mqttuser0004/pubsub/1
+mqttPubSubTopic: mqttuser0004/pubsub/+
+mqttPubSubTopic: mqttuser0004/pubsub/#
+userPassword: {MD5}2Br6pPDSEDIEvUlu9+s+MA==
+
+## create user mqttuser0005
+#         password=mqttuser0005,
+#         passhash={SHA}jKnxeEDGR14kE8AR7yuVFOelhz4=
+#         base64passhash=e1NIQX1qS254ZUVER1IxNGtFOEFSN3l1VkZPZWxoejQ9
+objectClass: top
+dn:uid=mqttuser0005,ou=testdevice,dc=emqx,dc=io
+objectClass: mqttUser
+objectClass: mqttDevice
+objectClass: mqttSecurity
+uid: mqttuser0005
+isEnabled: TRUE
+mqttPublishTopic: mqttuser0005/pub/1
+mqttPublishTopic: mqttuser0005/pub/+
+mqttPublishTopic: mqttuser0005/pub/#
+mqttSubscriptionTopic: mqttuser0005/sub/1
+mqttSubscriptionTopic: mqttuser0005/sub/+
+mqttSubscriptionTopic: mqttuser0005/sub/#
+mqttPubSubTopic: mqttuser0005/pubsub/1
+mqttPubSubTopic: mqttuser0005/pubsub/+
+mqttPubSubTopic: mqttuser0005/pubsub/#
+userPassword: {SHA}jKnxeEDGR14kE8AR7yuVFOelhz4=
+

+ 46 - 0
apps/emqx_ldap/test/data/emqx.schema

@@ -0,0 +1,46 @@
+#
+# Preliminary Apple OS X Native LDAP Schema
+# This file is subject to change.
+#
+attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.1.3 NAME 'isEnabled'
+	EQUALITY booleanMatch
+	SYNTAX 1.3.6.1.4.1.1466.115.121.1.7
+	SINGLE-VALUE
+	USAGE userApplications )
+
+attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.1 NAME ( 'mqttPublishTopic' 'mpt' )
+	EQUALITY caseIgnoreMatch
+	SUBSTR caseIgnoreSubstringsMatch
+	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
+	USAGE userApplications )
+attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.2 NAME ( 'mqttSubscriptionTopic' 'mst' )
+	EQUALITY caseIgnoreMatch
+	SUBSTR caseIgnoreSubstringsMatch
+	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
+	USAGE userApplications )
+attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.3 NAME ( 'mqttPubSubTopic' 'mpst' )
+	EQUALITY caseIgnoreMatch
+	SUBSTR caseIgnoreSubstringsMatch
+	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
+	USAGE userApplications )
+attributetype ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4.4 NAME ( 'mqttAccountName' 'man' )
+	EQUALITY caseIgnoreMatch
+	SUBSTR caseIgnoreSubstringsMatch
+	SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
+	USAGE userApplications )
+
+
+objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.4 NAME 'mqttUser'
+	AUXILIARY
+	MAY ( mqttPublishTopic $ mqttSubscriptionTopic $ mqttPubSubTopic $ mqttAccountName) )
+
+objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.2 NAME 'mqttDevice'
+	SUP top
+	STRUCTURAL
+	MUST ( uid )
+	MAY ( isEnabled ) )
+
+objectclass ( 1.3.6.1.4.1.11.2.53.2.2.3.1.2.3.3 NAME 'mqttSecurity'
+	SUP top
+	AUXILIARY
+	MAY ( userPassword $ userPKCS12 $ pwdAttribute $ pwdLockout ) )

+ 195 - 0
apps/emqx_ldap/test/emqx_ldap_SUITE.erl

@@ -0,0 +1,195 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-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").
+-include_lib("eldap/include/eldap.hrl").
+
+-define(LDAP_HOST, "ldap").
+-define(LDAP_RESOURCE_MOD, emqx_ldap).
+
+all() ->
+    [
+        {group, tcp},
+        {group, ssl}
+    ].
+
+groups() ->
+    Cases = emqx_common_test_helpers:all(?MODULE),
+    [
+        {tcp, Cases},
+        {ssl, Cases}
+    ].
+
+init_per_group(Group, Config) ->
+    [{group, Group} | Config].
+
+end_per_group(_, Config) ->
+    proplists:delete(group, Config).
+
+init_per_suite(Config) ->
+    Port = port(tcp),
+    case emqx_common_test_helpers:is_tcp_server_available(?LDAP_HOST, 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(Config)
+    ).
+
+perform_lifecycle_check(ResourceId, InitialConfig) ->
+    {ok, #{config := CheckedConfig}} =
+        emqx_resource:check_config(?LDAP_RESOURCE_MOD, InitialConfig),
+    {ok, #{
+        state := #{pool_name := PoolName} = State,
+        status := InitialStatus
+    }} = emqx_resource:create_local(
+        ResourceId,
+        ?CONNECTOR_RESOURCE_GROUP,
+        ?LDAP_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, [#eldap_entry{attributes = [_, _ | _]}]},
+        emqx_resource:query(ResourceId, test_query_no_attr())
+    ),
+    ?assertMatch(
+        {ok, [#eldap_entry{attributes = [{"mqttAccountName", _}]}]},
+        emqx_resource:query(ResourceId, test_query_with_attr())
+    ),
+    ?assertMatch(
+        {ok, _},
+        emqx_resource:query(
+            ResourceId,
+            test_query_with_attr_and_timeout()
+        )
+    ),
+    ?assertMatch({ok, []}, emqx_resource:query(ResourceId, test_query_not_exists())),
+    ?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, _}, emqx_resource:query(ResourceId, test_query_no_attr())),
+    ?assertMatch({ok, _}, emqx_resource:query(ResourceId, test_query_with_attr())),
+    ?assertMatch(
+        {ok, _},
+        emqx_resource:query(
+            ResourceId,
+            test_query_with_attr_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(Config) ->
+    RawConfig = list_to_binary(
+        io_lib:format(
+            ""
+            "\n"
+            "    auto_reconnect = true\n"
+            "    username= \"cn=root,dc=emqx,dc=io\"\n"
+            "    password = public\n"
+            "    pool_size = 8\n"
+            "    server = \"~s:~b\"\n"
+            "    base_object=\"uid=${username},ou=testdevice,dc=emqx,dc=io\"\n"
+            "    filter =\"(objectClass=mqttUser)\"\n"
+            "    ~ts\n"
+            "",
+            [?LDAP_HOST, port(Config), ssl(Config)]
+        )
+    ),
+
+    {ok, LDConfig} = hocon:binary(RawConfig),
+    #{<<"config">> => LDConfig}.
+
+test_query_no_attr() ->
+    {query, data()}.
+
+test_query_with_attr() ->
+    {query, data(), ["mqttAccountName"]}.
+
+test_query_with_attr_and_timeout() ->
+    {query, data(), ["mqttAccountName"], 5000}.
+
+test_query_not_exists() ->
+    {query, #{username => <<"not_exists">>}}.
+
+data() ->
+    #{username => <<"mqttuser0001">>}.
+
+port(tcp) -> 389;
+port(ssl) -> 636;
+port(Config) -> port(proplists:get_value(group, Config)).
+
+ssl(Config) ->
+    case proplists:get_value(group, Config) of
+        tcp ->
+            "ssl.enable=false";
+        ssl ->
+            "ssl.enable=true\n"
+            "ssl.cacertfile=\"etc/openldap/cacert.pem\""
+    end.

+ 234 - 0
apps/emqx_ldap/test/emqx_ldap_filter_SUITE.erl

@@ -0,0 +1,234 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_ldap_filter_SUITE).
+
+-compile(nowarn_export_all).
+-compile(export_all).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("stdlib/include/assert.hrl").
+
+-import(eldap, [
+    'and'/1,
+    'or'/1,
+    'not'/1,
+    equalityMatch/2,
+    substrings/2,
+    present/1,
+    greaterOrEqual/2,
+    lessOrEqual/2,
+    approxMatch/2,
+    extensibleMatch/2
+]).
+
+all() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+groups() ->
+    [].
+
+init_per_suite(Config) ->
+    Config.
+
+end_per_suite(_Config) ->
+    _ = application:stop(emqx_connector).
+
+% %%------------------------------------------------------------------------------
+% %% Testcases
+% %%------------------------------------------------------------------------------
+
+t_and(_Config) ->
+    ?assertEqual('and'([equalityMatch("a", "1")]), parse("(&(a=1))")),
+    ?assertEqual(
+        'and'([equalityMatch("a", "1"), (equalityMatch("b", "2"))]),
+        parse("(&(a=1)(b=2))")
+    ),
+    ?assertMatch({error, _}, scan_and_parse("(&)")).
+
+t_or(_Config) ->
+    ?assertEqual('or'([equalityMatch("a", "1")]), parse("(|(a=1))")),
+    ?assertEqual(
+        'or'([equalityMatch("a", "1"), (equalityMatch("b", "2"))]),
+        parse("(|(a=1)(b=2))")
+    ),
+    ?assertMatch({error, _}, scan_and_parse("(|)")).
+
+t_not(_Config) ->
+    ?assertEqual('not'(equalityMatch("a", "1")), parse("(!(a=1))")),
+    ?assertMatch({error, _}, scan_and_parse("(!)")),
+    ?assertMatch({error, _}, scan_and_parse("(!(a=1)(b=1))")).
+
+t_equalityMatch(_Config) ->
+    ?assertEqual(equalityMatch("attr", "value"), parse("(attr=value)")),
+    ?assertEqual(equalityMatch("attr", "value"), parse("(attr = value)")),
+    ?assertMatch({error, _}, scan_and_parse("(attr=)")),
+    ?assertMatch({error, _}, scan_and_parse("(=)")),
+    ?assertMatch({error, _}, scan_and_parse("(=value)")).
+
+t_substrings_initial(_Config) ->
+    ?assertEqual(substrings("attr", [{initial, "initial"}]), parse("(attr=initial*)")),
+    ?assertEqual(
+        substrings("attr", [{initial, "initial"}, {any, "a"}]),
+        parse("(attr=initial*a*)")
+    ),
+    ?assertEqual(
+        substrings("attr", [{initial, "initial"}, {any, "a"}, {any, "b"}]),
+        parse("(attr=initial*a*b*)")
+    ).
+
+t_substrings_final(_Config) ->
+    ?assertEqual(substrings("attr", [{final, "final"}]), parse("(attr=*final)")),
+    ?assertEqual(
+        substrings("attr", [{any, "a"}, {final, "final"}]),
+        parse("(attr=*a*final)")
+    ),
+    ?assertEqual(
+        substrings("attr", [{any, "a"}, {any, "b"}, {final, "final"}]),
+        parse("(attr=*a*b*final)")
+    ).
+
+t_substrings_initial_final(_Config) ->
+    ?assertEqual(
+        substrings("attr", [{initial, "initial"}, {final, "final"}]),
+        parse("(attr=initial*final)")
+    ),
+    ?assertEqual(
+        substrings("attr", [{initial, "initial"}, {any, "a"}, {final, "final"}]),
+        parse("(attr=initial*a*final)")
+    ),
+    ?assertEqual(
+        substrings(
+            "attr",
+            [{initial, "initial"}, {any, "a"}, {any, "b"}, {final, "final"}]
+        ),
+        parse("(attr=initial*a*b*final)")
+    ).
+
+t_substrings_only_any(_Config) ->
+    ?assertEqual(present("attr"), parse("(attr=*)")),
+    ?assertEqual(substrings("attr", [{any, "a"}]), parse("(attr=*a*)")),
+    ?assertEqual(
+        substrings("attr", [{any, "a"}, {any, "b"}]),
+        parse("(attr=*a*b*)")
+    ).
+
+t_greaterOrEqual(_Config) ->
+    ?assertEqual(greaterOrEqual("attr", "value"), parse("(attr>=value)")),
+    ?assertEqual(greaterOrEqual("attr", "value"), parse("(attr >= value  )")),
+    ?assertMatch({error, _}, scan_and_parse("(attr>=)")),
+    ?assertMatch({error, _}, scan_and_parse("(>=)")),
+    ?assertMatch({error, _}, scan_and_parse("(>=value)")).
+
+t_lessOrEqual(_Config) ->
+    ?assertEqual(lessOrEqual("attr", "value"), parse("(attr<=value)")),
+    ?assertEqual(lessOrEqual("attr", "value"), parse("( attr <= value  )")),
+    ?assertMatch({error, _}, scan_and_parse("(attr<=)")),
+    ?assertMatch({error, _}, scan_and_parse("(<=)")),
+    ?assertMatch({error, _}, scan_and_parse("(<=value)")).
+
+t_present(_Config) ->
+    ?assertEqual(present("attr"), parse("(attr=*)")),
+    ?assertEqual(present("attr"), parse("( attr = *  )")).
+
+t_approxMatch(_Config) ->
+    ?assertEqual(approxMatch("attr", "value"), parse("(attr~=value)")),
+    ?assertEqual(approxMatch("attr", "value"), parse("( attr ~= value  )")),
+    ?assertMatch({error, _}, scan_and_parse("(attr~=)")),
+    ?assertMatch({error, _}, scan_and_parse("(~=)")),
+    ?assertMatch({error, _}, scan_and_parse("(~=value)")).
+
+t_extensibleMatch_dn(_Config) ->
+    ?assertEqual(
+        extensibleMatch("value", [{type, "attr"}, {dnAttributes, true}]), parse("(attr:dn:=value)")
+    ),
+    ?assertEqual(
+        extensibleMatch("value", [{type, "attr"}, {dnAttributes, true}]),
+        parse("(  attr:dn  :=  value  )")
+    ).
+
+t_extensibleMatch_rule(_Config) ->
+    ?assertEqual(
+        extensibleMatch("value", [{type, "attr"}, {matchingRule, "objectClass"}]),
+        parse("(attr:objectClass:=value)")
+    ),
+    ?assertEqual(
+        extensibleMatch("value", [{type, "attr"}, {matchingRule, "objectClass"}]),
+        parse("(   attr:objectClass  :=  value )")
+    ).
+
+t_extensibleMatch_dn_rule(_Config) ->
+    ?assertEqual(
+        extensibleMatch(
+            "value",
+            [
+                {type, "attr"},
+                {dnAttributes, true},
+                {matchingRule, "objectClass"}
+            ]
+        ),
+        parse("(attr:dn:objectClass:=value)")
+    ),
+    ?assertEqual(
+        extensibleMatch(
+            "value",
+            [
+                {type, "attr"},
+                {dnAttributes, true},
+                {matchingRule, "objectClass"}
+            ]
+        ),
+        parse("(  attr:dn:objectClass  :=value)")
+    ).
+
+t_extensibleMatch_no_dn_rule(_Config) ->
+    ?assertEqual(extensibleMatch("value", [{type, "attr"}]), parse("(attr:=value)")),
+    ?assertEqual(extensibleMatch("value", [{type, "attr"}]), parse("(  attr  :=  value  )")).
+
+t_extensibleMatch_no_type_dn(_Config) ->
+    ?assertEqual(
+        extensibleMatch("value", [{matchingRule, "objectClass"}]),
+        parse("(:objectClass:=value)")
+    ),
+    ?assertEqual(
+        extensibleMatch("value", [{matchingRule, "objectClass"}]),
+        parse("(   :objectClass  :=  value )")
+    ).
+
+t_extensibleMatch_no_type_no_dn(_Config) ->
+    ?assertEqual(
+        extensibleMatch(
+            "value",
+            [{dnAttributes, true}, {matchingRule, "objectClass"}]
+        ),
+        parse("(:dn:objectClass:=value)")
+    ),
+    ?assertEqual(
+        extensibleMatch(
+            "value",
+            [{dnAttributes, true}, {matchingRule, "objectClass"}]
+        ),
+        parse("(  :dn:objectClass  :=value)")
+    ).
+
+t_extensibleMatch_error(_Config) ->
+    ?assertMatch({error, _}, scan_and_parse("(:dn:=value)")),
+    ?assertMatch({error, _}, scan_and_parse("(::=value)")),
+    ?assertMatch({error, _}, scan_and_parse("(:=)")),
+    ?assertMatch({error, _}, scan_and_parse("(attr:=)")).
+
+t_error(_Config) ->
+    ?assertMatch({error, _}, scan_and_parse("(attr=value")),
+    ?assertMatch({error, _}, scan_and_parse("attr=value")),
+    ?assertMatch({error, _}, scan_and_parse("(a=b)(c=d)")).
+
+% %%------------------------------------------------------------------------------
+% %% Helpers
+% %%------------------------------------------------------------------------------
+parse(Str) ->
+    {ok, Res} = scan_and_parse(Str),
+    Res.
+
+scan_and_parse(Str) ->
+    emqx_ldap_filter_parser:scan_and_parse(Str).

+ 25 - 0
rel/i18n/emqx_ldap.hocon

@@ -0,0 +1,25 @@
+emqx_ldap {
+
+server.desc:
+"""The IPv4 or IPv6 address or the hostname to connect to.<br/>
+A host entry has the following form: `Host[:Port]`.<br/>
+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"""
+
+}

+ 3 - 0
scripts/ct/run.sh

@@ -225,6 +225,9 @@ for dep in ${CT_DEPS}; do
         greptimedb)
         greptimedb)
             FILES+=( '.ci/docker-compose-file/docker-compose-greptimedb.yaml' )
             FILES+=( '.ci/docker-compose-file/docker-compose-greptimedb.yaml' )
             ;;
             ;;
+        ldap)
+            FILES+=( '.ci/docker-compose-file/docker-compose-ldap.yaml' )
+            ;;
         *)
         *)
             echo "unknown_ct_dependency $dep"
             echo "unknown_ct_dependency $dep"
             exit 1
             exit 1