Просмотр исходного кода

Merge pull request #11670 from id/0925-sync-r53-to-master

sync r53 to master
Zaiming (Stone) Shi 2 лет назад
Родитель
Сommit
cedd90e89f
39 измененных файлов с 724 добавлено и 128 удалено
  1. 5 3
      .github/CODEOWNERS
  2. 1 0
      .github/workflows/_push-entrypoint.yaml
  3. 1 1
      .github/workflows/build_packages_cron.yaml
  4. 1 1
      apps/emqx/include/emqx_release.hrl
  5. 1 1
      apps/emqx/test/emqx_bpapi_static_checks.erl
  6. 1 1
      apps/emqx_bridge_kafka/test/emqx_bridge_kafka_impl_producer_SUITE.erl
  7. 43 7
      apps/emqx_ctl/src/emqx_ctl.erl
  8. 16 4
      apps/emqx_ctl/test/emqx_ctl_SUITE.erl
  9. 9 7
      apps/emqx_dashboard/src/emqx_dashboard_api.erl
  10. 7 1
      apps/emqx_dashboard/src/emqx_dashboard_swagger.erl
  11. 2 2
      apps/emqx_dashboard/src/emqx_dashboard_token.erl
  12. 3 2
      apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl
  13. 4 3
      apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl
  14. 1 1
      apps/emqx_dashboard/test/emqx_dashboard_api_test_helpers.erl
  15. 1 1
      apps/emqx_dashboard/test/emqx_dashboard_haproxy_SUITE.erl
  16. 1 1
      apps/emqx_dashboard_rbac/README.md
  17. 2 2
      apps/emqx_dashboard_rbac/rebar.config
  18. 14 5
      apps/emqx_dashboard_rbac/src/emqx_dashboard_rbac.erl
  19. 14 2
      apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl
  20. 2 1
      apps/emqx_dashboard_sso/rebar.config
  21. 2 1
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src
  22. 8 2
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl
  23. 50 30
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl
  24. 16 20
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl
  25. 240 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl
  26. 132 0
      apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml_api.erl
  27. 1 1
      apps/emqx_dashboard_sso/test/emqx_dashboard_sso_ldap_SUITE.erl
  28. 1 1
      apps/emqx_management/test/emqx_mgmt_api_plugins_SUITE.erl
  29. 3 3
      bin/nodetool
  30. 2 2
      deploy/charts/emqx-enterprise/Chart.yaml
  31. 20 4
      dev
  32. 8 8
      rebar.config
  33. 9 0
      rel/i18n/emqx_dashboard_sso_api.hocon
  34. 7 0
      rel/i18n/emqx_dashboard_sso_ldap.hocon
  35. 28 0
      rel/i18n/emqx_dashboard_sso_saml.hocon
  36. 9 7
      scripts/rel/cut.sh
  37. 7 3
      scripts/rel/sync-remotes.sh
  38. 50 0
      scripts/shelltest/parse-git-ref.test
  39. 2 0
      scripts/spellcheck/dicts/emqx.txt

+ 5 - 3
.github/CODEOWNERS

@@ -5,9 +5,11 @@
 /apps/emqx/                @emqx/emqx-review-board @lafirest
 /apps/emqx_authn/          @emqx/emqx-review-board @JimMoen @savonarola
 /apps/emqx_authz/          @emqx/emqx-review-board @JimMoen @savonarola
-/apps/emqx_connector/      @emqx/emqx-review-board @JimMoen
+/apps/emqx_connector/      @emqx/emqx-review-board
 /apps/emqx_dashboard/      @emqx/emqx-review-board @JimMoen @lafirest
-/apps/emqx_exhook/         @emqx/emqx-review-board @JimMoen @lafirest
+/apps/emqx_dashboard_rbac/ @emqx/emqx-review-board @lafirest
+/apps/emqx_dashboard_sso/  @emqx/emqx-review-board @JimMoen @lafirest
+/apps/emqx_exhook/         @emqx/emqx-review-board @JimMoen @HJianBo
 /apps/emqx_ft/             @emqx/emqx-review-board @savonarola @keynslug
 /apps/emqx_gateway/        @emqx/emqx-review-board @lafirest
 /apps/emqx_management/     @emqx/emqx-review-board @lafirest @sstrigler
@@ -18,7 +20,7 @@
 /apps/emqx_rule_engine/    @emqx/emqx-review-board @kjellwinblad
 /apps/emqx_slow_subs/      @emqx/emqx-review-board @lafirest
 /apps/emqx_statsd/         @emqx/emqx-review-board @JimMoen
-/apps/emqx_durable_storage/ @emqx/emqx-review-board @ieQu1 @keynslug 
+/apps/emqx_durable_storage/ @emqx/emqx-review-board @ieQu1 @keynslug
 
 ## CI
 /deploy/  @emqx/emqx-review-board @Rory-Z

+ 1 - 0
.github/workflows/_push-entrypoint.yaml

@@ -13,6 +13,7 @@ on:
       - 'master'
       - 'release-51'
       - 'release-52'
+      - 'release-53'
       - 'ci/**'
 
 env:

+ 1 - 1
.github/workflows/build_packages_cron.yaml

@@ -21,8 +21,8 @@ jobs:
       matrix:
         profile:
           - ['emqx', 'master']
-          - ['emqx-enterprise', 'release-51']
           - ['emqx-enterprise', 'release-52']
+          - ['emqx-enterprise', 'release-53']
         otp:
           - 25.3.2-2
         arch:

+ 1 - 1
apps/emqx/include/emqx_release.hrl

@@ -35,7 +35,7 @@
 -define(EMQX_RELEASE_CE, "5.2.1").
 
 %% Enterprise edition
--define(EMQX_RELEASE_EE, "5.2.1").
+-define(EMQX_RELEASE_EE, "5.3.0-alpha.1").
 
 %% The HTTP API version
 -define(EMQX_API_VERSION, "5.0").

+ 1 - 1
apps/emqx/test/emqx_bpapi_static_checks.erl

@@ -48,7 +48,7 @@
 
 %% Applications and modules we wish to ignore in the analysis:
 -define(IGNORED_APPS,
-    "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria, amqp_client, rabbit_common"
+    "gen_rpc, recon, redbug, observer_cli, snabbkaffe, ekka, mria, amqp_client, rabbit_common, esaml"
 ).
 -define(IGNORED_MODULES, "emqx_rpc").
 -define(FORCE_DELETED_MODULES, [

+ 1 - 1
apps/emqx_bridge_kafka/test/emqx_bridge_kafka_impl_producer_SUITE.erl

@@ -1260,7 +1260,7 @@ auth_header_() ->
     auth_header_(<<"admin">>, <<"public">>).
 
 auth_header_(Username, Password) ->
-    {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(Username, Password),
     {"Authorization", "Bearer " ++ binary_to_list(Token)}.
 
 api_path(Parts) ->

+ 43 - 7
apps/emqx_ctl/src/emqx_ctl.erl

@@ -44,6 +44,10 @@
     usage/2
 ]).
 
+-export([
+    eval_erl/1
+]).
+
 %% Exports mainly for test cases
 -export([
     format/2,
@@ -119,7 +123,7 @@ run_command(Cmd, Args) when is_atom(Cmd) ->
     Start = erlang:monotonic_time(),
     Result =
         case lookup_command(Cmd) of
-            [{Mod, Fun}] ->
+            {ok, {Mod, Fun}} ->
                 try
                     apply(Mod, Fun, [Args])
                 catch
@@ -127,13 +131,15 @@ run_command(Cmd, Args) when is_atom(Cmd) ->
                         ?LOG_ERROR(#{
                             msg => "ctl_command_crashed",
                             stacktrace => Stacktrace,
-                            reason => Reason
+                            reason => Reason,
+                            module => Mod,
+                            function => Fun
                         }),
                         {error, Reason}
                 end;
-            Error ->
+            {error, Reason} ->
                 help(),
-                Error
+                {error, Reason}
         end,
     Duration = erlang:convert_time_unit(erlang:monotonic_time() - Start, native, millisecond),
 
@@ -144,12 +150,22 @@ run_command(Cmd, Args) when is_atom(Cmd) ->
     ),
     Result.
 
--spec lookup_command(cmd()) -> [{module(), atom()}] | {error, any()}.
+-spec lookup_command(cmd()) -> {module(), atom()} | {error, any()}.
+lookup_command(eval_erl) ->
+    %% So far 'emqx ctl eval_erl Expr' is a undocumented hidden command.
+    %% For backward compatibility,
+    %% the documented command 'emqx eval Expr' has the expression parsed
+    %% in the remsh node (nodetool).
+    %%
+    %% 'eval_erl' is added for two purposes
+    %% 1. 'emqx eval Expr' can be audited
+    %% 2. 'emqx ctl eval_erl Expr' simplifies the scripting part
+    {ok, {?MODULE, eval_erl}};
 lookup_command(Cmd) when is_atom(Cmd) ->
     case is_initialized() of
         true ->
             case ets:match(?CMD_TAB, {{'_', Cmd}, '$1', '_'}) of
-                [El] -> El;
+                [[{M, F}]] -> {ok, {M, F}};
                 [] -> {error, cmd_not_found}
             end;
         false ->
@@ -319,7 +335,7 @@ audit_log(Level, From, Log) ->
     case lookup_command(audit) of
         {error, _} ->
             ignore;
-        [{Mod, Fun}] ->
+        {ok, {Mod, Fun}} ->
             try
                 apply(Mod, Fun, [Level, From, Log])
             catch
@@ -339,3 +355,23 @@ audit_level({ok, _}, Duration) when Duration >= ?TOO_SLOW -> warning;
 audit_level(ok, _Duration) -> info;
 audit_level({ok, _}, _Duration) -> info;
 audit_level(_, _) -> error.
+
+eval_erl([Parsed | _] = Expr) when is_tuple(Parsed) ->
+    eval_expr(Expr);
+eval_erl([String]) ->
+    % convenience to users, if they forgot a trailing
+    % '.' add it for them.
+    Normalized =
+        case lists:reverse(String) of
+            [$. | _] -> String;
+            R -> lists:reverse([$. | R])
+        end,
+    % then scan and parse the string
+    {ok, Scanned, _} = erl_scan:string(Normalized),
+    {ok, Parsed} = erl_parse:parse_exprs(Scanned),
+    {ok, Value} = eval_expr(Parsed),
+    print("~p~n", [Value]).
+
+eval_expr(Parsed) ->
+    {value, Value, _} = erl_eval:exprs(Parsed, []),
+    {ok, Value}.

+ 16 - 4
apps/emqx_ctl/test/emqx_ctl_SUITE.erl

@@ -40,8 +40,8 @@ t_reg_unreg_command(_) ->
         fun(_CtlSrv) ->
             emqx_ctl:register_command(cmd1, {?MODULE, cmd1_fun}),
             emqx_ctl:register_command(cmd2, {?MODULE, cmd2_fun}),
-            ?assertEqual([{?MODULE, cmd1_fun}], emqx_ctl:lookup_command(cmd1)),
-            ?assertEqual([{?MODULE, cmd2_fun}], emqx_ctl:lookup_command(cmd2)),
+            ?assertEqual({?MODULE, cmd1_fun}, lookup_command(cmd1)),
+            ?assertEqual({?MODULE, cmd2_fun}, lookup_command(cmd2)),
             ?assertEqual(
                 [{cmd1, ?MODULE, cmd1_fun}, {cmd2, ?MODULE, cmd2_fun}],
                 emqx_ctl:get_commands()
@@ -49,8 +49,8 @@ t_reg_unreg_command(_) ->
             emqx_ctl:unregister_command(cmd1),
             emqx_ctl:unregister_command(cmd2),
             ct:sleep(100),
-            ?assertEqual({error, cmd_not_found}, emqx_ctl:lookup_command(cmd1)),
-            ?assertEqual({error, cmd_not_found}, emqx_ctl:lookup_command(cmd2)),
+            ?assertEqual({error, cmd_not_found}, lookup_command(cmd1)),
+            ?assertEqual({error, cmd_not_found}, lookup_command(cmd2)),
             ?assertEqual([], emqx_ctl:get_commands())
         end
     ).
@@ -79,6 +79,12 @@ t_print(_) ->
     ?assertEqual("~!@#$%^&*()", emqx_ctl:print("~ts", [<<"~!@#$%^&*()">>])),
     unmock_print().
 
+t_eval_erl(_) ->
+    mock_print(),
+    Expected = atom_to_list(node()) ++ "\n",
+    ?assertEqual(Expected, emqx_ctl:run_command(["eval_erl", "node()"])),
+    unmock_print().
+
 t_usage(_) ->
     CmdParams1 = "emqx_cmd_1 param1 param2",
     CmdDescr1 = "emqx_cmd_1 is a test command means nothing",
@@ -129,3 +135,9 @@ mock_print() ->
 
 unmock_print() ->
     meck:unload(emqx_ctl).
+
+lookup_command(Cmd) ->
+    case emqx_ctl:lookup_command(Cmd) of
+        {ok, {Mod, Fun}} -> {Mod, Fun};
+        Error -> Error
+    end.

+ 9 - 7
apps/emqx_dashboard/src/emqx_dashboard_api.erl

@@ -77,7 +77,7 @@ schema("/login") ->
             summary => <<"Dashboard authentication">>,
             'requestBody' => fields([username, password]),
             responses => #{
-                200 => fields([token, version, license]),
+                200 => fields([role, token, version, license]),
                 401 => response_schema(401)
             },
             security => []
@@ -219,14 +219,16 @@ login(post, #{body := Params}) ->
     Username = maps:get(<<"username">>, Params),
     Password = maps:get(<<"password">>, Params),
     case emqx_dashboard_admin:sign_token(Username, Password) of
-        {ok, Token} ->
+        {ok, Role, Token} ->
             ?SLOG(info, #{msg => "dashboard_login_successful", username => Username}),
             Version = iolist_to_binary(proplists:get_value(version, emqx_sys:info())),
-            {200, #{
-                token => Token,
-                version => Version,
-                license => #{edition => emqx_release:edition()}
-            }};
+            {200,
+                filter_result(#{
+                    role => Role,
+                    token => Token,
+                    version => Version,
+                    license => #{edition => emqx_release:edition()}
+                })};
         {error, R} ->
             ?SLOG(info, #{msg => "dashboard_login_failed", username => Username, reason => R}),
             {401, ?BAD_USERNAME_OR_PWD, <<"Auth failed">>}

+ 7 - 1
apps/emqx_dashboard/src/emqx_dashboard_swagger.erl

@@ -28,7 +28,7 @@
 -export([error_codes/1, error_codes/2]).
 -export([file_schema/1]).
 -export([base_path/0]).
--export([relative_uri/1]).
+-export([relative_uri/1, get_relative_uri/1]).
 -export([compose_filters/2]).
 
 -export([
@@ -212,6 +212,12 @@ base_path() ->
 relative_uri(Uri) ->
     base_path() ++ Uri.
 
+-spec get_relative_uri(uri_string:uri_string()) -> {ok, uri_string:uri_string()} | error.
+get_relative_uri(<<?BASE_PATH, Path/binary>>) ->
+    {ok, Path};
+get_relative_uri(_Path) ->
+    error.
+
 file_schema(FileName) ->
     #{
         content => #{

+ 2 - 2
apps/emqx_dashboard/src/emqx_dashboard_token.erl

@@ -56,7 +56,7 @@
 %%--------------------------------------------------------------------
 %% jwt function
 -spec sign(User :: dashboard_user(), Password :: binary()) ->
-    {ok, Token :: binary()} | {error, Reason :: term()}.
+    {ok, dashboard_user_role(), Token :: binary()} | {error, Reason :: term()}.
 sign(User, Password) ->
     do_sign(User, Password).
 
@@ -120,7 +120,7 @@ do_sign(#?ADMIN{username = Username} = User, Password) ->
     Role = emqx_dashboard_admin:role(User),
     JWTRec = format(Token, Username, Role, ExpTime),
     _ = mria:transaction(?DASHBOARD_SHARD, fun mnesia:write/1, [JWTRec]),
-    {ok, Token}.
+    {ok, Role, Token}.
 
 -spec do_verify(_, Token :: binary()) ->
     Result ::

+ 3 - 2
apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl

@@ -120,6 +120,7 @@ t_rest_api(_Config) ->
     ?assertEqual(
         [
             filter_req(#{
+                <<"backend">> => <<"local">>,
                 <<"username">> => <<"admin">>,
                 <<"description">> => <<"administrator">>,
                 <<"role">> => ?ROLE_SUPERUSER
@@ -269,7 +270,7 @@ auth_header_() ->
     auth_header_(<<"admin">>, <<"public">>).
 
 auth_header_(Username, Password) ->
-    {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(Username, Password),
     {"Authorization", "Bearer " ++ binary_to_list(Token)}.
 
 api_path(Parts) ->
@@ -286,6 +287,6 @@ filter_req(Req) ->
 -else.
 
 filter_req(Req) ->
-    maps:without([role, <<"role">>], Req).
+    maps:without([role, <<"role">>, backend, <<"backend">>], Req).
 
 -endif.

+ 4 - 3
apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl

@@ -174,15 +174,16 @@ t_clean_token(_) ->
     Password = <<"public_www1">>,
     NewPassword = <<"public_www2">>,
     {ok, _} = emqx_dashboard_admin:add_user(Username, Password, ?ROLE_SUPERUSER, <<"desc">>),
-    {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
-    FakeReq = #{method => <<"GET">>},
+    {ok, _, Token} = emqx_dashboard_admin:sign_token(Username, Password),
+    FakePath = erlang:list_to_binary(emqx_dashboard_swagger:relative_uri("/fake")),
+    FakeReq = #{method => <<"GET">>, path => FakePath},
     {ok, Username} = emqx_dashboard_admin:verify_token(FakeReq, Token),
     %% change password
     {ok, _} = emqx_dashboard_admin:change_password(Username, Password, NewPassword),
     timer:sleep(5),
     {error, not_found} = emqx_dashboard_admin:verify_token(FakeReq, Token),
     %% remove user
-    {ok, Token2} = emqx_dashboard_admin:sign_token(Username, NewPassword),
+    {ok, _, Token2} = emqx_dashboard_admin:sign_token(Username, NewPassword),
     {ok, Username} = emqx_dashboard_admin:verify_token(FakeReq, Token2),
     {ok, _} = emqx_dashboard_admin:remove_user(Username),
     timer:sleep(5),

+ 1 - 1
apps/emqx_dashboard/test/emqx_dashboard_api_test_helpers.erl

@@ -116,7 +116,7 @@ auth_header(Username) ->
     auth_header(Username, <<"public">>).
 
 auth_header(Username, Password) ->
-    {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(Username, Password),
     {"Authorization", "Bearer " ++ binary_to_list(Token)}.
 
 multipart_formdata_request(Url, Fields, Files) ->

+ 1 - 1
apps/emqx_dashboard/test/emqx_dashboard_haproxy_SUITE.erl

@@ -55,7 +55,7 @@ t_status(_Config) ->
         [binary, {active, false}, {packet, raw}]
     ),
     ok = gen_tcp:send(Socket, ranch_proxy_header:header(ProxyInfo)),
-    {ok, Token} = emqx_dashboard_admin:sign_token(<<"admin">>, <<"public">>),
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(<<"admin">>, <<"public">>),
     ok = gen_tcp:send(
         Socket,
         "GET /status HTTP/1.1\r\n"

+ 1 - 1
apps/emqx_dashboard_rbac/README.md

@@ -12,4 +12,4 @@ Please see our [contributing.md](../../CONTRIBUTING.md).
 
 ## License
 
-See [APL](../../APL.txt).
+EMQ Business Source License 1.1, refer to [LICENSE](BSL.txt).

+ 2 - 2
apps/emqx_dashboard_rbac/rebar.config

@@ -1,6 +1,6 @@
 %% -*- mode: erlang; -*-
-
 {erl_opts, [debug_info]}.
+
 {deps, [
-        {emqx_connector, {path, "../../apps/emqx_dashboard"}}
+  {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}
 ]}.

+ 14 - 5
apps/emqx_dashboard_rbac/src/emqx_dashboard_rbac.erl

@@ -12,9 +12,15 @@
 %%=====================================================================
 %% API
 check_rbac(Req, Extra) ->
-    Method = cowboy_req:method(Req),
     Role = role(Extra),
-    check_rbac_with_method(Role, Method).
+    Method = cowboy_req:method(Req),
+    AbsPath = cowboy_req:path(Req),
+    case emqx_dashboard_swagger:get_relative_uri(AbsPath) of
+        {ok, Path} ->
+            check_rbac(Role, Method, Path);
+        _ ->
+            false
+    end.
 
 %% For compatibility
 role(#?ADMIN{role = undefined}) ->
@@ -35,11 +41,14 @@ valid_role(Role) ->
             {error, <<"Role does not exist">>}
     end.
 %% ===================================================================
-check_rbac_with_method(?ROLE_SUPERUSER, _) ->
+check_rbac(?ROLE_SUPERUSER, _, _) ->
+    true;
+check_rbac(?ROLE_VIEWER, <<"GET">>, _) ->
     true;
-check_rbac_with_method(?ROLE_VIEWER, <<"GET">>) ->
+%% this API is a special case
+check_rbac(?ROLE_VIEWER, <<"POST">>, <<"/logout">>) ->
     true;
-check_rbac_with_method(_, _) ->
+check_rbac(_, _, _) ->
     false.
 
 role_list() ->

+ 14 - 2
apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl

@@ -135,8 +135,9 @@ t_clean_token(_) ->
     Desc = <<"desc">>,
     NewDesc = <<"new desc">>,
     {ok, _} = emqx_dashboard_admin:add_user(Username, Password, ?ROLE_SUPERUSER, Desc),
-    {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
-    FakeReq = #{method => <<"GET">>},
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(Username, Password),
+    FakePath = erlang:list_to_binary(emqx_dashboard_swagger:relative_uri("/fake")),
+    FakeReq = #{method => <<"GET">>, path => FakePath},
     {ok, Username} = emqx_dashboard_admin:verify_token(FakeReq, Token),
     %% change description
     {ok, _} = emqx_dashboard_admin:update_user(Username, ?ROLE_SUPERUSER, NewDesc),
@@ -148,6 +149,17 @@ t_clean_token(_) ->
     {error, not_found} = emqx_dashboard_admin:verify_token(FakeReq, Token),
     ok.
 
+t_login_out(_) ->
+    Username = <<"admin_token">>,
+    Password = <<"public_www1">>,
+    Desc = <<"desc">>,
+    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, ?ROLE_SUPERUSER, Desc),
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(Username, Password),
+    FakePath = erlang:list_to_binary(emqx_dashboard_swagger:relative_uri("/logout")),
+    FakeReq = #{method => <<"POST">>, path => FakePath},
+    {ok, Username} = emqx_dashboard_admin:verify_token(FakeReq, Token),
+    ok.
+
 add_default_superuser() ->
     {ok, _NewUser} = emqx_dashboard_admin:add_user(
         ?DEFAULT_SUPERUSER,

+ 2 - 1
apps/emqx_dashboard_sso/rebar.config

@@ -3,5 +3,6 @@
 {erl_opts, [debug_info]}.
 {deps, [
         {emqx_ldap, {path, "../../apps/emqx_ldap"}},
-        {emqx_dashboard, {path, "../../apps/emqx_dashboard"}}
+        {emqx_dashboard, {path, "../../apps/emqx_dashboard"}},
+        {esaml, {git, "https://github.com/emqx/esaml", {tag, "v1.1.1"}}}
 ]}.

+ 2 - 1
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.app.src

@@ -6,7 +6,8 @@
         kernel,
         stdlib,
         emqx_dashboard,
-        emqx_ldap
+        emqx_ldap,
+        esaml
     ]},
     {mod, {emqx_dashboard_sso_app, []}},
     {env, []},

+ 8 - 2
apps/emqx_dashboard_sso/src/emqx_dashboard_sso.erl

@@ -5,6 +5,7 @@
 -module(emqx_dashboard_sso).
 
 -include_lib("hocon/include/hoconsc.hrl").
+-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
 
 -export([
     hocon_ref/1,
@@ -38,7 +39,9 @@
     {ok, NewState :: state()} | {error, Reason :: term()}.
 -callback destroy(State :: state()) -> ok.
 -callback login(request(), State :: state()) ->
-    {ok, Token :: binary()} | {error, Reason :: term()}.
+    {ok, dashboard_user_role(), Token :: binary()}
+    | {redirect, tuple()}
+    | {error, Reason :: term()}.
 
 %%------------------------------------------------------------------------------
 %% Callback Interface
@@ -76,4 +79,7 @@ provider(Backend) ->
     maps:get(Backend, backends()).
 
 backends() ->
-    #{ldap => emqx_dashboard_sso_ldap}.
+    #{
+        ldap => emqx_dashboard_sso_ldap,
+        saml => emqx_dashboard_sso_saml
+    }.

+ 50 - 30
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_api.erl

@@ -16,6 +16,8 @@
     ref/1
 ]).
 
+-import(emqx_dashboard_sso, [provider/1]).
+
 -export([
     api_spec/0,
     fields/1,
@@ -31,8 +33,9 @@
     backend/2
 ]).
 
--export([sso_parameters/1]).
+-export([sso_parameters/1, login_reply/2]).
 
+-define(REDIRECT, 'REDIRECT').
 -define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD').
 -define(BAD_REQUEST, 'BAD_REQUEST').
 -define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND').
@@ -74,6 +77,9 @@ schema("/sso") ->
             }
         }
     };
+%% Visit "/sso/login/saml" to start the saml authentication process -- first check to see if
+%% we are already logged in, otherwise we will make an AuthnRequest and send it to
+%% our IDP
 schema("/sso/login/:backend") ->
     #{
         'operationId' => login,
@@ -83,7 +89,9 @@ schema("/sso/login/:backend") ->
             parameters => backend_name_in_path(),
             'requestBody' => login_union(),
             responses => #{
-                200 => emqx_dashboard_api:fields([token, version, license]),
+                200 => emqx_dashboard_api:fields([role, token, version, license]),
+                %% Redirect to IDP for saml
+                302 => response_schema(302),
                 401 => response_schema(401),
                 404 => response_schema(404)
             },
@@ -126,8 +134,10 @@ schema("/sso/:backend") ->
 fields(backend_status) ->
     emqx_dashboard_sso_schema:common_backend_schema(emqx_dashboard_sso:types()).
 
-%% -------------------------------------------------------------------------------------------------
+%%--------------------------------------------------------------------
 %% API
+%%--------------------------------------------------------------------
+
 running(get, _Request) ->
     SSO = emqx:get_config([dashboard_sso], #{}),
     {200,
@@ -141,28 +151,25 @@ running(get, _Request) ->
             maps:values(SSO)
         )}.
 
-login(post, #{bindings := #{backend := Backend}, body := Sign}) ->
+login(post, #{bindings := #{backend := Backend}} = Request) ->
     case emqx_dashboard_sso_manager:lookup_state(Backend) of
         undefined ->
-            {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>};
+            {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
         State ->
-            Provider = emqx_dashboard_sso:provider(Backend),
-            case emqx_dashboard_sso:login(Provider, Sign, State) of
-                {ok, Token} ->
-                    ?SLOG(info, #{msg => "dashboard_sso_login_successful", request => Sign}),
-                    Version = iolist_to_binary(proplists:get_value(version, emqx_sys:info())),
-                    {200, #{
-                        token => Token,
-                        version => Version,
-                        license => #{edition => emqx_release:edition()}
-                    }};
+            case emqx_dashboard_sso:login(provider(Backend), Request, State) of
+                {ok, Role, Token} ->
+                    ?SLOG(info, #{msg => "dashboard_sso_login_successful", request => Request}),
+                    {200, login_reply(Role, Token)};
+                {redirect, Redirect} ->
+                    ?SLOG(info, #{msg => "dashboard_sso_login_redirect", request => Request}),
+                    Redirect;
                 {error, Reason} ->
                     ?SLOG(info, #{
                         msg => "dashboard_sso_login_failed",
-                        request => Sign,
+                        request => Request,
                         reason => Reason
                     }),
-                    {401, ?BAD_USERNAME_OR_PWD, <<"Auth failed">>}
+                    {401, #{code => ?BAD_USERNAME_OR_PWD, message => <<"Auth failed">>}}
             end
     end.
 
@@ -179,7 +186,7 @@ sso(get, _Request) ->
 backend(get, #{bindings := #{backend := Type}}) ->
     case emqx:get_config([dashboard_sso, Type], undefined) of
         undefined ->
-            {404, ?BACKEND_NOT_FOUND};
+            {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
         Backend ->
             {200, to_json(Backend)}
     end;
@@ -193,8 +200,12 @@ backend(delete, #{bindings := #{backend := Backend}}) ->
 sso_parameters(Params) ->
     backend_name_as_arg(query, [local], <<"local">>) ++ Params.
 
-%% -------------------------------------------------------------------------------------------------
+%%--------------------------------------------------------------------
 %% internal
+%%--------------------------------------------------------------------
+
+response_schema(302) ->
+    emqx_dashboard_swagger:error_codes([?REDIRECT], ?DESC(redirect));
 response_schema(401) ->
     emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401));
 response_schema(404) ->
@@ -227,24 +238,25 @@ on_backend_update(Backend, Config, Fun) ->
     Result = valid_config(Backend, Config, Fun),
     handle_backend_update_result(Result, Config).
 
-valid_config(Backend, Config, Fun) ->
-    case maps:get(<<"backend">>, Config, undefined) of
-        Backend ->
-            Fun(Backend, Config);
-        _ ->
-            {error, invalid_config}
-    end.
+valid_config(Backend, #{<<"backend">> := Backend} = Config, Fun) ->
+    Fun(Backend, Config);
+valid_config(_, _, _) ->
+    {error, invalid_config}.
 
-handle_backend_update_result({ok, _}, Config) ->
+handle_backend_update_result({ok, #{backend := saml} = State}, _Config) ->
+    {200, to_json(maps:without([idp_meta, sp], State))};
+handle_backend_update_result({ok, _State}, Config) ->
     {200, to_json(Config)};
 handle_backend_update_result(ok, _) ->
     204;
 handle_backend_update_result({error, not_exists}, _) ->
-    {404, ?BACKEND_NOT_FOUND, <<"Backend not found">>};
+    {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
 handle_backend_update_result({error, already_exists}, _) ->
-    {400, ?BAD_REQUEST, <<"Backend already exists">>};
+    {400, #{code => ?BAD_REQUEST, message => <<"Backend already exists">>}};
+handle_backend_update_result({error, failed_to_load_metadata}, _) ->
+    {400, #{code => ?BAD_REQUEST, message => <<"Failed to load metadata">>}};
 handle_backend_update_result({error, Reason}, _) ->
-    {400, ?BAD_REQUEST, Reason}.
+    {400, #{code => ?BAD_REQUEST, message => Reason}}.
 
 to_json(Data) ->
     emqx_utils_maps:jsonable_map(
@@ -253,3 +265,11 @@ to_json(Data) ->
             {K, emqx_utils_maps:binary_string(V)}
         end
     ).
+
+login_reply(Role, Token) ->
+    #{
+        role => Role,
+        token => Token,
+        version => iolist_to_binary(proplists:get_value(version, emqx_sys:info())),
+        license => #{edition => emqx_release:edition()}
+    }.

+ 16 - 20
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_ldap.erl

@@ -106,26 +106,22 @@ ensure_bind_password(Config) ->
     Config#{bind_password => <<"${password}">>}.
 
 adjust_ldap_fields(Fields) ->
-    adjust_ldap_fields(Fields, []).
-
-adjust_ldap_fields([{filter, Meta} | T], Acc) ->
-    adjust_ldap_fields(
-        T,
-        [
-            {filter, Meta#{
-                default => <<"(objectClass=user)">>,
-                example => <<"(objectClass=user)">>
-            }}
-            | Acc
-        ]
-    );
-adjust_ldap_fields([Any | T], Acc) ->
-    adjust_ldap_fields(T, [Any | Acc]);
-adjust_ldap_fields([], Acc) ->
-    lists:reverse(Acc).
+    lists:map(fun adjust_ldap_field/1, Fields).
+
+adjust_ldap_field({base_dn, Meta}) ->
+    {base_dn, maps:remove(example, Meta)};
+adjust_ldap_field({filter, Meta}) ->
+    Default = <<"(& (objectClass=person) (uid=${username}))">>,
+    {filter, Meta#{
+        desc => ?DESC(filter),
+        default => Default,
+        example => Default
+    }};
+adjust_ldap_field(Any) ->
+    Any.
 
 login(
-    #{<<"username">> := Username} = Req,
+    #{body := #{<<"username">> := Username} = Sign} = _Req,
     #{
         query_timeout := Timeout,
         resource_id := ResourceId
@@ -134,7 +130,7 @@ login(
     case
         emqx_resource:simple_sync_query(
             ResourceId,
-            {query, Req, [], Timeout}
+            {query, Sign, [], Timeout}
         )
     of
         {ok, []} ->
@@ -143,7 +139,7 @@ login(
             case
                 emqx_resource:simple_sync_query(
                     ResourceId,
-                    {bind, Req}
+                    {bind, Sign}
                 )
             of
                 ok ->

+ 240 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml.erl

@@ -0,0 +1,240 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_saml).
+
+-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
+-include_lib("emqx/include/logger.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("esaml/include/esaml.hrl").
+
+-behaviour(emqx_dashboard_sso).
+
+-export([
+    hocon_ref/0,
+    login_ref/0,
+    fields/1,
+    desc/1
+]).
+
+%% emqx_dashboard_sso callbacks
+-export([
+    create/1,
+    update/2,
+    destroy/1
+]).
+
+-export([login/2, callback/2]).
+
+-dialyzer({nowarn_function, do_create/1}).
+
+-define(DIR, <<"saml_sp_certs">>).
+
+%%------------------------------------------------------------------------------
+%% Hocon Schema
+%%------------------------------------------------------------------------------
+
+hocon_ref() ->
+    hoconsc:ref(?MODULE, saml).
+
+login_ref() ->
+    hoconsc:ref(?MODULE, login).
+
+fields(saml) ->
+    emqx_dashboard_sso_schema:common_backend_schema([saml]) ++
+        [
+            {dashboard_addr, fun dashboard_addr/1},
+            {idp_metadata_url, fun idp_metadata_url/1},
+            {sp_sign_request, fun sp_sign_request/1},
+            {sp_public_key, fun sp_public_key/1},
+            {sp_private_key, fun sp_private_key/1}
+        ];
+fields(login) ->
+    [
+        emqx_dashboard_sso_schema:backend_schema([saml])
+    ].
+
+dashboard_addr(type) -> binary();
+%% without any path
+dashboard_addr(desc) -> ?DESC(dashboard_addr);
+dashboard_addr(default) -> <<"https://127.0.0.1:18083">>;
+dashboard_addr(_) -> undefined.
+
+%% TOOD: support raw xml metadata in hocon (maybe?🤔)
+idp_metadata_url(type) -> binary();
+idp_metadata_url(desc) -> ?DESC(idp_metadata_url);
+idp_metadata_url(default) -> <<"https://idp.example.com">>;
+idp_metadata_url(_) -> undefined.
+
+sp_sign_request(type) -> boolean();
+sp_sign_request(desc) -> ?DESC(sign_request);
+sp_sign_request(default) -> false;
+sp_sign_request(_) -> undefined.
+
+sp_public_key(type) -> binary();
+sp_public_key(desc) -> ?DESC(sp_public_key);
+sp_public_key(default) -> <<"Pub Key">>;
+sp_public_key(_) -> undefined.
+
+sp_private_key(type) -> binary();
+sp_private_key(desc) -> ?DESC(sp_private_key);
+sp_private_key(required) -> false;
+sp_private_key(format) -> <<"password">>;
+sp_private_key(sensitive) -> true;
+sp_private_key(_) -> undefined.
+
+desc(saml) ->
+    "saml";
+desc(_) ->
+    undefined.
+
+%%------------------------------------------------------------------------------
+%% APIs
+%%------------------------------------------------------------------------------
+
+create(#{sp_sign_request := true} = Config) ->
+    try
+        do_create(ensure_cert_and_key(Config))
+    catch
+        Kind:Error ->
+            Msg = failed_to_ensure_cert_and_key,
+            ?SLOG(error, #{msg => Msg, kind => Kind, error => Error}),
+            {error, Msg}
+    end;
+create(#{sp_sign_request := false} = Config) ->
+    do_create(Config#{key => undefined, certificate => undefined}).
+
+do_create(
+    #{
+        dashboard_addr := DashboardAddr,
+        idp_metadata_url := IDPMetadataURL,
+        sp_sign_request := SpSignRequest,
+        sp_private_key := KeyPath,
+        sp_public_key := CertPath
+    } = Config
+) ->
+    {ok, _} = application:ensure_all_started(esaml),
+    BaseURL = binary_to_list(DashboardAddr) ++ "/api/v5",
+    SP = esaml_sp:setup(#esaml_sp{
+        key = maybe_load_cert_or_key(KeyPath, fun esaml_util:load_private_key/1),
+        certificate = maybe_load_cert_or_key(CertPath, fun esaml_util:load_certificate/1),
+        sp_sign_requests = SpSignRequest,
+        trusted_fingerprints = [],
+        consume_uri = BaseURL ++ "/sso/saml/acs",
+        metadata_uri = BaseURL ++ "/sso/saml/metadata",
+        %% TODO: support conf org and contact
+        org = #esaml_org{
+            name = "EMQX",
+            displayname = "EMQX Dashboard",
+            url = DashboardAddr
+        },
+        tech = #esaml_contact{
+            name = "EMQX",
+            email = "contact@emqx.io"
+        }
+    }),
+    try
+        IdpMeta = esaml_util:load_metadata(binary_to_list(IDPMetadataURL)),
+        State = Config,
+        {ok, State#{idp_meta => IdpMeta, sp => SP}}
+    catch
+        Kind:Error ->
+            Reason = failed_to_load_metadata,
+            ?SLOG(error, #{msg => Reason, kind => Kind, error => Error}),
+            {error, Reason}
+    end.
+
+update(Config0, State) ->
+    destroy(State),
+    create(Config0).
+
+destroy(_State) ->
+    _ = file:del_dir_r(emqx_tls_lib:pem_dir(?DIR)),
+    _ = application:stop(esaml),
+    ok.
+
+login(
+    #{headers := Headers} = _Req,
+    #{sp := SP, idp_meta := #esaml_idp_metadata{login_location = IDP}} = _State
+) ->
+    SignedXml = esaml_sp:generate_authn_request(IDP, SP),
+    Target = esaml_binding:encode_http_redirect(IDP, SignedXml, <<>>),
+    RespHeaders = #{<<"Cache-Control">> => <<"no-cache">>, <<"Pragma">> => <<"no-cache">>},
+    Redirect =
+        case is_msie(Headers) of
+            true ->
+                Html = esaml_binding:encode_http_post(IDP, SignedXml, <<>>),
+                {200, RespHeaders, Html};
+            false ->
+                RespHeaders1 = RespHeaders#{<<"Location">> => Target},
+                {302, RespHeaders1, <<"Redirecting...">>}
+        end,
+    {redirect, Redirect}.
+
+callback(_Req = #{body := Body}, #{sp := SP} = _State) ->
+    case do_validate_assertion(SP, fun esaml_util:check_dupe_ets/2, Body) of
+        {ok, Assertion, _RelayState} ->
+            Subject = Assertion#esaml_assertion.subject,
+            Username = iolist_to_binary(Subject#esaml_subject.name),
+            ensure_user_exists(Username);
+        {error, Reason0} ->
+            Reason = [
+                "Access denied, assertion failed validation:\n", io_lib:format("~p\n", [Reason0])
+            ],
+            {error, iolist_to_binary(Reason)}
+    end.
+
+do_validate_assertion(SP, DuplicateFun, Body) ->
+    PostVals = cow_qs:parse_qs(Body),
+    SAMLEncoding = proplists:get_value(<<"SAMLEncoding">>, PostVals),
+    SAMLResponse = proplists:get_value(<<"SAMLResponse">>, PostVals),
+    RelayState = proplists:get_value(<<"RelayState">>, PostVals),
+    case (catch esaml_binding:decode_response(SAMLEncoding, SAMLResponse)) of
+        {'EXIT', Reason} ->
+            {error, {bad_decode, Reason}};
+        Xml ->
+            case esaml_sp:validate_assertion(Xml, DuplicateFun, SP) of
+                {ok, A} -> {ok, A, RelayState};
+                {error, E} -> {error, E}
+            end
+    end.
+
+%%------------------------------------------------------------------------------
+%% Internal functions
+%%------------------------------------------------------------------------------
+
+ensure_cert_and_key(#{sp_public_key := Cert, sp_private_key := Key} = Config) ->
+    case
+        emqx_tls_lib:ensure_ssl_files(
+            ?DIR, #{enable => ture, certfile => Cert, keyfile => Key}, #{}
+        )
+    of
+        {ok, #{certfile := CertPath, keyfile := KeyPath} = _NSSL} ->
+            Config#{sp_public_key => CertPath, sp_private_key => KeyPath};
+        {error, #{which_options := KeyPath}} ->
+            error({missing_key, lists:flatten(KeyPath)})
+    end.
+
+maybe_load_cert_or_key(undefined, _) ->
+    undefined;
+maybe_load_cert_or_key(Path, Func) ->
+    Func(Path).
+
+is_msie(Headers) ->
+    UA = maps:get(<<"user-agent">>, Headers, <<"">>),
+    not (binary:match(UA, <<"MSIE">>) =:= nomatch).
+
+%% TODO: unify with emqx_dashboard_sso_manager:ensure_user_exists/1
+ensure_user_exists(Username) ->
+    case emqx_dashboard_admin:lookup_user(saml, Username) of
+        [User] ->
+            emqx_dashboard_token:sign(User, <<>>);
+        [] ->
+            case emqx_dashboard_admin:add_sso_user(saml, Username, ?ROLE_VIEWER, <<>>) of
+                {ok, _} ->
+                    ensure_user_exists(Username);
+                Error ->
+                    Error
+            end
+    end.

+ 132 - 0
apps/emqx_dashboard_sso/src/emqx_dashboard_sso_saml_api.erl

@@ -0,0 +1,132 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_sso_saml_api).
+
+-behaviour(minirest_api).
+
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("emqx/include/logger.hrl").
+
+-import(hoconsc, [
+    mk/2,
+    array/1,
+    enum/1,
+    ref/1
+]).
+
+-import(emqx_dashboard_sso, [provider/1]).
+
+-export([
+    api_spec/0,
+    paths/0,
+    schema/1,
+    namespace/0
+]).
+
+-export([
+    sp_saml_metadata/2,
+    sp_saml_callback/2
+]).
+
+-define(REDIRECT, 'REDIRECT').
+-define(BAD_USERNAME_OR_PWD, 'BAD_USERNAME_OR_PWD').
+-define(BACKEND_NOT_FOUND, 'BACKEND_NOT_FOUND').
+-define(TAGS, <<"Dashboard Single Sign-On">>).
+
+namespace() -> "dashboard_sso".
+
+api_spec() ->
+    emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false, translate_body => false}).
+
+paths() ->
+    [
+        "/sso/saml/acs",
+        "/sso/saml/metadata"
+    ].
+
+%% Handles HTTP-POST bound assertions coming back from the IDP.
+schema("/sso/saml/acs") ->
+    #{
+        'operationId' => sp_saml_callback,
+        post => #{
+            tags => [?TAGS],
+            desc => ?DESC(saml_sso_acs),
+            %% 'requestbody' => urlencoded_request_body(),
+            responses => #{
+                302 => response_schema(302),
+                401 => response_schema(401),
+                404 => response_schema(404)
+            },
+            security => []
+        }
+    };
+schema("/sso/saml/metadata") ->
+    #{
+        'operationId' => sp_saml_metadata,
+        get => #{
+            tags => [?TAGS],
+            desc => ?DESC(sp_saml_metadata),
+            'requestbody' => saml_metadata_response(),
+            responses => #{
+                200 => emqx_dashboard_api:fields([token, version, license]),
+                404 => response_schema(404)
+            }
+        }
+    }.
+
+%%--------------------------------------------------------------------
+%% API
+%%--------------------------------------------------------------------
+
+sp_saml_metadata(get, _Req) ->
+    case emqx_dashboard_sso_manager:lookup_state(saml) of
+        undefined ->
+            {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
+        #{sp := SP} = _State ->
+            SignedXml = esaml_sp:generate_metadata(SP),
+            Metadata = xmerl:export([SignedXml], xmerl_xml),
+            {200, #{<<"Content-Type">> => <<"text/xml">>}, erlang:iolist_to_binary(Metadata)}
+    end.
+
+sp_saml_callback(post, Req) ->
+    case emqx_dashboard_sso_manager:lookup_state(saml) of
+        undefined ->
+            {404, #{code => ?BACKEND_NOT_FOUND, message => <<"Backend not found">>}};
+        State ->
+            case (provider(saml)):callback(Req, State) of
+                {ok, Role, Token} ->
+                    {200, emqx_dashboard_sso_api:login_reply(Role, Token)};
+                {error, Reason} ->
+                    ?SLOG(info, #{
+                        msg => "dashboard_saml_sso_login_failed",
+                        request => Req,
+                        reason => Reason
+                    }),
+                    {403, #{code => <<"UNAUTHORIZED">>, message => Reason}}
+            end
+    end.
+
+%%--------------------------------------------------------------------
+%% internal
+%%--------------------------------------------------------------------
+
+response_schema(302) ->
+    emqx_dashboard_swagger:error_codes([?REDIRECT], ?DESC(redirect));
+response_schema(401) ->
+    emqx_dashboard_swagger:error_codes([?BAD_USERNAME_OR_PWD], ?DESC(login_failed401));
+response_schema(404) ->
+    emqx_dashboard_swagger:error_codes([?BACKEND_NOT_FOUND], ?DESC(backend_not_found)).
+
+saml_metadata_response() ->
+    #{
+        'content' => #{
+            'application/xml' => #{
+                schema => #{
+                    type => <<"string">>,
+                    format => <<"binary">>
+                }
+            }
+        }
+    }.

+ 1 - 1
apps/emqx_dashboard_sso/test/emqx_dashboard_sso_ldap_SUITE.erl

@@ -101,7 +101,7 @@ t_first_login(_) ->
     },
     %% this API is authorization-free
     {ok, 200, Result} = request_without_authorization(post, Path, Req),
-    ?assertMatch(#{license := _, token := _}, decode_json(Result)),
+    ?assertMatch(#{license := _, token := _, role := ?ROLE_VIEWER}, decode_json(Result)),
     ?assertMatch(
         [#?ADMIN{username = ?SSO_USERNAME(ldap, ?LDAP_USER)}],
         emqx_dashboard_admin:lookup_user(ldap, ?LDAP_USER)

+ 1 - 1
apps/emqx_management/test/emqx_mgmt_api_plugins_SUITE.erl

@@ -252,7 +252,7 @@ describe_plugins(Name) ->
     end.
 
 install_plugin(FilePath) ->
-    {ok, Token} = emqx_dashboard_admin:sign_token(<<"admin">>, <<"public">>),
+    {ok, _Role, Token} = emqx_dashboard_admin:sign_token(<<"admin">>, <<"public">>),
     Path = emqx_mgmt_api_test_util:api_path(["plugins", "install"]),
     case
         emqx_mgmt_api_test_util:upload_request(

+ 3 - 3
bin/nodetool

@@ -142,9 +142,9 @@ do(Args) ->
         ["eval" | ListOfArgs] ->
             Parsed = parse_eval_args(ListOfArgs),
             % and evaluate it on the remote node
-            case rpc:call(TargetNode, erl_eval, exprs, [Parsed, [] ]) of
-                {value, Value, _} ->
-                    io:format ("~p~n",[Value]);
+            case rpc:call(TargetNode, emqx_ctl, eval_erl, [Parsed]) of
+                {ok, Value} ->
+                    io:format("~p~n",[Value]);
                 {badrpc, Reason} ->
                     io:format("RPC to ~p failed: ~p~n", [TargetNode, Reason]),
                     halt(1)

+ 2 - 2
deploy/charts/emqx-enterprise/Chart.yaml

@@ -14,8 +14,8 @@ type: application
 
 # This is the chart version. This version number should be incremented each time you make changes
 # to the chart and its templates, including the app version.
-version: 5.2.1
+version: 5.3.0-alpha.1
 
 # This is the version number of the application being deployed. This version number should be
 # incremented each time you make changes to the application.
-appVersion: 5.2.1
+appVersion: 5.3.0-alpha.1

+ 20 - 4
dev

@@ -37,6 +37,7 @@ COMMANDS:
   ctl:    Equivalent to 'emqx ctl'.
           ctl command arguments should be passed after '--'
           e.g. $0 ctl -- help
+  eval:   Evaluate an Erlang expression
 
 OPTIONS:
   -p|--profile:      emqx | emqx-enterprise, defaults to 'PROFILE' env.
@@ -83,6 +84,10 @@ case "${1:-novalue}" in
         COMMAND='ctl'
         shift
         ;;
+    eval)
+        COMMAND='eval'
+        shift
+        ;;
     help)
         usage
         exit 0
@@ -425,14 +430,22 @@ remsh() {
         $EPMD_ARGS
 }
 
+# evaluate erlang expression in remsh node
+eval_remsh_erl() {
+    local tmpnode erl_code
+    tmpnode="$(gen_tmp_node_name)"
+    erl_code="$1"
+    # shellcheck disable=SC2086 # need to expand EMQD_ARGS
+    erl -name "$tmpnode" -setcookie "$COOKIE" -hidden -noshell $EPMD_ARGS -eval "$erl_code" 2>&1
+}
+
 ctl() {
     if [ -z "${PASSTHROUGH_ARGS:-}" ]; then
         logerr "Need at least one argument for ctl command"
         logerr "e.g. $0 ctl -- help"
         exit 1
     fi
-    local tmpnode args rpc_code output result
-    tmpnode="$(gen_tmp_node_name)"
+    local args rpc_code output result
     args="$(make_erlang_args "${PASSTHROUGH_ARGS[@]}")"
     rpc_code="
         case rpc:call('$EMQX_NODE_NAME', emqx_ctl, run_command, [[$args]]) of
@@ -443,8 +456,7 @@ ctl() {
             init:stop(1)
         end"
     set +e
-    # shellcheck disable=SC2086
-    output="$(erl -name "$tmpnode" -setcookie "$COOKIE" -hidden -noshell $EPMD_ARGS -eval "$rpc_code" 2>&1)"
+    output="$(eval_remsh_erl "$rpc_code")"
     result=$?
     if [ $result -eq 0 ]; then
         echo -e "$output"
@@ -464,4 +476,8 @@ case "$COMMAND" in
     ctl)
         ctl
         ;;
+    eval)
+        PASSTHROUGH_ARGS=('eval_erl' "${PASSTHROUGH_ARGS[@]}")
+        ctl
+        ;;
 esac

+ 8 - 8
rebar.config

@@ -84,14 +84,14 @@
     %% in conflict by erlavro and rocketmq
     , {jsone, {git, "https://github.com/emqx/jsone.git", {tag, "1.7.1"}}}
     , {uuid, {git, "https://github.com/okeuday/uuid.git", {tag, "v2.0.6"}}}
-%% trace
-      , {opentelemetry_api, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api"}}
-      , {opentelemetry, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry"}}
-      %% log metrics
-      , {opentelemetry_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_experimental"}}
-      , {opentelemetry_api_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api_experimental"}}
-      %% export
-      , {opentelemetry_exporter, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_exporter"}}
+    %% trace
+    , {opentelemetry_api, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api"}}
+    , {opentelemetry, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry"}}
+    %% log metrics
+    , {opentelemetry_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_experimental"}}
+    , {opentelemetry_api_experimental, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_api_experimental"}}
+    %% export
+    , {opentelemetry_exporter, {git_subdir, "https://github.com/emqx/opentelemetry-erlang", {tag, "v1.3.0-emqx"}, "apps/opentelemetry_exporter"}}
     ]}.
 
 {xref_ignores,

+ 9 - 0
rel/i18n/emqx_dashboard_sso_api.hocon

@@ -30,6 +30,15 @@ delete_backend.desc:
 delete_backend.label:
 """Delete Backend"""
 
+saml_sso_acs.desc:
+"""SAML SSO ACS URL"""
+
+sp_saml_metadata.desc:
+"""SP SAML Metadata"""
+
+redirect.desc:
+"""Redirect to IDP SSO login page"""
+
 login_failed401.desc:
 """Login failed. Bad username or password"""
 

+ 7 - 0
rel/i18n/emqx_dashboard_sso_ldap.hocon

@@ -8,4 +8,11 @@ query_timeout.desc:
 
 query_timeout.label:
 """Query Timeout"""
+
+filter.desc:
+"""The filter for matching users in LDAP is by default `(&(objectClass=person)(uid=${username}))`. For Active Directory, it should be set to `(&(objectClass=user)(sAMAccountName=${username}))` by default. Please refer to [LDAP Filters](https://ldap.com/ldap-filters/) for more details."""
+
+filter.label:
+"""Filter"""
+
 }

+ 28 - 0
rel/i18n/emqx_dashboard_sso_saml.hocon

@@ -0,0 +1,28 @@
+emqx_dashboard_sso_saml {
+
+dashboard_addr.desc:
+"""The address of the EMQX Dashboard."""
+dashboard_addr.label:
+"""Dashboard Address"""
+
+idp_metadata_url.desc:
+"""The URL of the IdP metadata."""
+idp_metadata_url.label:
+"""IdP Metadata URL"""
+
+sign_request.desc:
+"""Whether to sign the SAML request."""
+sign_request.label:
+"""Sign SAML Request"""
+
+sp_public_key.desc:
+"""The public key of the SP."""
+sp_public_key.label:
+"""SP Public Key"""
+
+sp_private_key.desc:
+"""The private key of the SP."""
+sp_private_key.label:
+"""SP Private Key"""
+
+}

+ 9 - 7
scripts/rel/cut.sh

@@ -22,6 +22,7 @@ options:
   -b|--base:         Specify the current release base branch, can be one of
                      release-51
                      release-52
+                     release-53
                      NOTE: this option should be used when --dryrun.
 
   --dryrun:          Do not actually create the git tag.
@@ -33,15 +34,10 @@ options:
                      If this option is absent, the tag found by git describe will be used
 
 
-For 5.1 series the current working branch must be 'release-51'
+For 5.X series the current working branch must be 'release-5X'
       --.--[  master  ]---------------------------.-----------.---
          \\                                      /
-          \`---[release-51]----(v5.1.1 | e5.1.1)
-
-For 5.2 series the current working branch must be 'release-52'
-      --.--[  master  ]---------------------------.-----------.---
-         \\                                      /
-          \`---[release-52]----(v5.2.1 | e5.2.1)
+          \`---[release-53]----(v5.3.1 | e5.3.1)
 EOF
 }
 
@@ -134,6 +130,12 @@ rel_branch() {
         e5.2.*)
             echo 'release-52'
             ;;
+        v5.3.*)
+            echo 'release-53'
+            ;;
+        e5.3.*)
+            echo 'release-53'
+            ;;
         *)
             logerr "Unsupported version tag $TAG"
             exit 1

+ 7 - 3
scripts/rel/sync-remotes.sh

@@ -5,7 +5,7 @@ set -euo pipefail
 # ensure dir
 cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/../.."
 
-BASE_BRANCHES=( 'release-52' 'release-51' 'master' )
+BASE_BRANCHES=( 'release-53' 'release-52' 'release-51' 'master' )
 
 usage() {
     cat <<EOF
@@ -18,9 +18,10 @@ options:
     It tries to merge (by default with --ff-only option)
     upstreams branches for the current working branch.
     The uppstream branch of the current branch are as below:
+    * release-53: []        # no upstream for 5.3 opensource edition
     * release-52: []        # no upstream for 5.2 opensource edition
     * release-51: []        # no upstream for 5.1 opensource edition
-    * master: [release-52]  # sync release-52 to master
+    * master: [release-53]  # sync release-53 to master
 
   -b|--base:
     The base branch of current working branch if currently is not
@@ -152,6 +153,9 @@ remote_refs() {
 upstream_branches() {
     local base="$1"
     case "$base" in
+        release-53)
+            remote_ref "$base"
+            ;;
         release-52)
             remote_ref "$base"
             ;;
@@ -159,7 +163,7 @@ upstream_branches() {
             remote_ref "$base"
             ;;
         master)
-            remote_refs "$base" 'release-52'
+            remote_refs "$base" 'release-53'
             ;;
     esac
 }

+ 50 - 0
scripts/shelltest/parse-git-ref.test

@@ -3,6 +3,11 @@
 Unrecognized tag: refs/tags/v5.2.0-foobar.1
 >>>= 1
 
+./parse-git-ref.sh refs/tags/v5.3.0-foobar.1
+>>>2
+Unrecognized tag: refs/tags/v5.3.0-foobar.1
+>>>= 1
+
 ./parse-git-ref.sh v5.2.0
 >>>2
 Unrecognized git ref: v5.2.0
@@ -18,6 +23,21 @@ Unrecognized git ref: v5.2.0-1
 Unrecognized git ref: e5.2.0-1
 >>>= 1
 
+./parse-git-ref.sh v5.3.0
+>>>2
+Unrecognized git ref: v5.3.0
+>>>= 1
+
+./parse-git-ref.sh v5.3.0-1
+>>>2
+Unrecognized git ref: v5.3.0-1
+>>>= 1
+
+./parse-git-ref.sh e5.3.0-1
+>>>2
+Unrecognized git ref: e5.3.0-1
+>>>= 1
+
 ./parse-git-ref.sh refs/tags/v5.1.0
 >>>
 {"profile": "emqx", "release": true, "latest": false}
@@ -33,6 +53,11 @@ Unrecognized git ref: e5.2.0-1
 {"profile": "emqx", "release": true, "latest": false}
 >>>= 0
 
+./parse-git-ref.sh refs/tags/v5.3.0-alpha.1
+>>>
+{"profile": "emqx", "release": true, "latest": false}
+>>>= 0
+
 ./parse-git-ref.sh refs/tags/v5.2.0-alpha-1
 >>>2
 Unrecognized tag: refs/tags/v5.2.0-alpha-1
@@ -43,6 +68,11 @@ Unrecognized tag: refs/tags/v5.2.0-alpha-1
 {"profile": "emqx", "release": true, "latest": false}
 >>>= 0
 
+./parse-git-ref.sh refs/tags/v5.3.0-beta.1
+>>>
+{"profile": "emqx", "release": true, "latest": false}
+>>>= 0
+
 ./parse-git-ref.sh refs/tags/v5.2.0-rc.1
 >>>
 {"profile": "emqx", "release": true, "latest": false}
@@ -63,16 +93,31 @@ Unrecognized tag: refs/tags/v5.2.0-alpha-1
 {"profile": "emqx-enterprise", "release": true, "latest": false}
 >>>= 0
 
+./parse-git-ref.sh refs/tags/e5.3.0-alpha.1
+>>>
+{"profile": "emqx-enterprise", "release": true, "latest": false}
+>>>= 0
+
 ./parse-git-ref.sh refs/tags/e5.2.0-beta.1
 >>>
 {"profile": "emqx-enterprise", "release": true, "latest": false}
 >>>= 0
 
+./parse-git-ref.sh refs/tags/e5.3.0-beta.1
+>>>
+{"profile": "emqx-enterprise", "release": true, "latest": false}
+>>>= 0
+
 ./parse-git-ref.sh refs/tags/e5.2.0-rc.1
 >>>
 {"profile": "emqx-enterprise", "release": true, "latest": false}
 >>>= 0
 
+./parse-git-ref.sh refs/tags/e5.3.0-rc.1
+>>>
+{"profile": "emqx-enterprise", "release": true, "latest": false}
+>>>= 0
+
 ./parse-git-ref.sh refs/tags/e5.1.99
 >>>
 {"profile": "emqx-enterprise", "release": true, "latest": true}
@@ -98,6 +143,11 @@ Unrecognized tag: refs/tags/v5.2.0-alpha-1
 {"profile": "emqx-enterprise", "release": false, "latest": false}
 >>>= 0
 
+./parse-git-ref.sh refs/heads/release-53
+>>>
+{"profile": "emqx-enterprise", "release": false, "latest": false}
+>>>= 0
+
 ./parse-git-ref.sh refs/heads/ci/foobar
 >>>
 {"profile": "emqx", "release": false, "latest": false}

+ 2 - 0
scripts/spellcheck/dicts/emqx.txt

@@ -286,3 +286,5 @@ FormatType
 RocketMQ
 Keyspace
 OpenTSDB
+saml
+idp