瀏覽代碼

Merge pull request #11610 from lafirest/feat/rbac

feat(dashboard): add RBAC feature for Dashboard
lafirest 2 年之前
父節點
當前提交
6fe846bf0e

+ 16 - 5
apps/emqx_dashboard/include/emqx_dashboard.hrl

@@ -15,23 +15,34 @@
 %%--------------------------------------------------------------------
 -define(ADMIN, emqx_admin).
 
+%% TODO:
+%% The predefined roles of the preliminary RBAC implementation,
+%% these may be removed when developing the full RBAC feature.
+%% In full RBAC feature, the role may be customised created and deleted,
+%% a predefined configuration would replace these macros.
+-define(ROLE_VIEWER, <<"viewer">>).
+-define(ROLE_SUPERUSER, <<"superuser">>).
+
+-define(ROLE_DEFAULT, ?ROLE_SUPERUSER).
+
 -record(?ADMIN, {
     username :: binary(),
     pwdhash :: binary(),
     description :: binary(),
-    role = undefined :: atom(),
-    %% not used so far, for future extension
-    extra = [] :: term()
+    role = ?ROLE_DEFAULT :: binary(),
+    extra = #{} :: map()
 }).
 
+-type dashboard_user_role() :: binary().
+-type dashboard_user() :: #?ADMIN{}.
+
 -define(ADMIN_JWT, emqx_admin_jwt).
 
 -record(?ADMIN_JWT, {
     token :: binary(),
     username :: binary(),
     exptime :: integer(),
-    %% not used so far, fur future extension
-    extra = [] :: term()
+    extra = #{} :: map()
 }).
 
 -define(TAB_COLLECT, emqx_collect).

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

@@ -205,13 +205,16 @@ authorize(Req) ->
         {basic, Username, Password} ->
             api_key_authorize(Req, Username, Password);
         {bearer, Token} ->
-            case emqx_dashboard_admin:verify_token(Token) of
+            case emqx_dashboard_admin:verify_token(Req, Token) of
                 ok ->
                     ok;
                 {error, token_timeout} ->
                     {401, 'TOKEN_TIME_OUT', <<"Token expired, get new token by POST /login">>};
                 {error, not_found} ->
-                    {401, 'BAD_TOKEN', <<"Get a token by POST /login">>}
+                    {401, 'BAD_TOKEN', <<"Get a token by POST /login">>};
+                {error, unauthorized_role} ->
+                    {403, 'UNAUTHORIZED_ROLE',
+                        <<"You don't have permission to access this resource">>}
             end;
         _ ->
             return_unauthorized(

+ 87 - 33
apps/emqx_dashboard/src/emqx_dashboard_admin.erl

@@ -30,10 +30,10 @@
 -export([mnesia/1]).
 
 -export([
-    add_user/3,
-    force_add_user/3,
+    add_user/4,
+    force_add_user/4,
     remove_user/1,
-    update_user/2,
+    update_user/3,
     lookup_user/1,
     change_password/2,
     change_password/3,
@@ -43,7 +43,7 @@
 
 -export([
     sign_token/2,
-    verify_token/1,
+    verify_token/2,
     destroy_token_by_username/2
 ]).
 -export([
@@ -56,10 +56,11 @@
     default_username/0
 ]).
 
+-export([role/1]).
+
 -export([backup_tables/0]).
 
 -type emqx_admin() :: #?ADMIN{}.
--define(BOOTSTRAP_USER_TAG, <<"bootstrap user">>).
 
 %%--------------------------------------------------------------------
 %% Mnesia bootstrap
@@ -98,18 +99,19 @@ add_default_user() ->
 %% API
 %%--------------------------------------------------------------------
 
--spec add_user(binary(), binary(), binary()) -> {ok, map()} | {error, any()}.
-add_user(Username, Password, Desc) when
+-spec add_user(binary(), binary(), dashboard_user_role(), binary()) -> {ok, map()} | {error, any()}.
+add_user(Username, Password, Role, Desc) when
     is_binary(Username), is_binary(Password)
 ->
-    case {legal_username(Username), legal_password(Password)} of
-        {ok, ok} -> do_add_user(Username, Password, Desc);
-        {{error, Reason}, _} -> {error, Reason};
-        {_, {error, Reason}} -> {error, Reason}
+    case {legal_username(Username), legal_password(Password), legal_role(Role)} of
+        {ok, ok, ok} -> do_add_user(Username, Password, Role, Desc);
+        {{error, Reason}, _, _} -> {error, Reason};
+        {_, {error, Reason}, _} -> {error, Reason};
+        {_, _, {error, Reason}} -> {error, Reason}
     end.
 
-do_add_user(Username, Password, Desc) ->
-    Res = mria:transaction(?DASHBOARD_SHARD, fun add_user_/3, [Username, Password, Desc]),
+do_add_user(Username, Password, Role, Desc) ->
+    Res = mria:transaction(?DASHBOARD_SHARD, fun add_user_/4, [Username, Password, Role, Desc]),
     return(Res).
 
 %% 0-9 or A-Z or a-z or $_
@@ -177,11 +179,12 @@ ascii_character_validate(Password) ->
 contain(Xs, Spec) -> lists:any(fun(X) -> lists:member(X, Spec) end, Xs).
 
 %% black-magic: force overwrite a user
-force_add_user(Username, Password, Desc) ->
+force_add_user(Username, Password, Role, Desc) ->
     AddFun = fun() ->
         mnesia:write(#?ADMIN{
             username = Username,
             pwdhash = hash(Password),
+            role = Role,
             description = Desc
         })
     end,
@@ -191,12 +194,17 @@ force_add_user(Username, Password, Desc) ->
     end.
 
 %% @private
-add_user_(Username, Password, Desc) ->
+add_user_(Username, Password, Role, Desc) ->
     case mnesia:wread({?ADMIN, Username}) of
         [] ->
-            Admin = #?ADMIN{username = Username, pwdhash = hash(Password), description = Desc},
+            Admin = #?ADMIN{
+                username = Username,
+                pwdhash = hash(Password),
+                role = Role,
+                description = Desc
+            },
             mnesia:write(Admin),
-            #{username => Username, description => Desc};
+            #{username => Username, role => Role, description => Desc};
         [_] ->
             mnesia:abort(<<"username_already_exist">>)
     end.
@@ -217,9 +225,27 @@ remove_user(Username) when is_binary(Username) ->
             {error, Reason}
     end.
 
--spec update_user(binary(), binary()) -> {ok, map()} | {error, term()}.
-update_user(Username, Desc) when is_binary(Username) ->
-    return(mria:transaction(?DASHBOARD_SHARD, fun update_user_/2, [Username, Desc])).
+-spec update_user(binary(), dashboard_user_role(), binary()) -> {ok, map()} | {error, term()}.
+update_user(Username, Role, Desc) when is_binary(Username) ->
+    case legal_role(Role) of
+        ok ->
+            case
+                return(
+                    mria:transaction(?DASHBOARD_SHARD, fun update_user_/3, [Username, Role, Desc])
+                )
+            of
+                {ok, {true, Result}} ->
+                    {ok, Result};
+                {ok, {false, Result}} ->
+                    %% role has changed, destroy the related token
+                    _ = emqx_dashboard_token:destroy_by_username(Username),
+                    {ok, Result};
+                Error ->
+                    Error
+            end;
+        Error ->
+            Error
+    end.
 
 hash(Password) ->
     SaltBin = emqx_dashboard_token:salt(),
@@ -240,18 +266,18 @@ sha256(SaltBin, Password) ->
     crypto:hash('sha256', <<SaltBin/binary, Password/binary>>).
 
 %% @private
-update_user_(Username, Desc) ->
+update_user_(Username, Role, Desc) ->
     case mnesia:wread({?ADMIN, Username}) of
         [] ->
             mnesia:abort(<<"username_not_found">>);
         [Admin] ->
-            mnesia:write(Admin#?ADMIN{description = Desc}),
-            #{username => Username, description => Desc}
+            mnesia:write(Admin#?ADMIN{role = Role, description = Desc}),
+            {role(Admin) =:= Role, #{username => Username, role => Role, description => Desc}}
     end.
 
 change_password(Username, OldPasswd, NewPasswd) when is_binary(Username) ->
     case check(Username, OldPasswd) of
-        ok -> change_password(Username, NewPasswd);
+        {ok, _} -> change_password(Username, NewPasswd);
         Error -> Error
     end.
 
@@ -298,12 +324,14 @@ all_users() ->
         fun(
             #?ADMIN{
                 username = Username,
-                description = Desc
+                description = Desc,
+                role = Role
             }
         ) ->
             #{
                 username => Username,
-                description => Desc
+                description => Desc,
+                role => ensure_role(Role)
             }
         end,
         ets:tab2list(?ADMIN)
@@ -320,9 +348,9 @@ check(_, undefined) ->
     {error, <<"password_not_provided">>};
 check(Username, Password) ->
     case lookup_user(Username) of
-        [#?ADMIN{pwdhash = PwdHash}] ->
+        [#?ADMIN{pwdhash = PwdHash} = User] ->
             case verify_hash(Password, PwdHash) of
-                ok -> ok;
+                ok -> {ok, User};
                 error -> {error, <<"password_error">>}
             end;
         [] ->
@@ -333,14 +361,14 @@ check(Username, Password) ->
 %% token
 sign_token(Username, Password) ->
     case check(Username, Password) of
-        ok ->
-            emqx_dashboard_token:sign(Username, Password);
+        {ok, User} ->
+            emqx_dashboard_token:sign(User, Password);
         Error ->
             Error
     end.
 
-verify_token(Token) ->
-    emqx_dashboard_token:verify(Token).
+verify_token(Req, Token) ->
+    emqx_dashboard_token:verify(Req, Token).
 
 destroy_token_by_username(Username, Token) ->
     case emqx_dashboard_token:lookup(Token) of
@@ -363,10 +391,36 @@ add_default_user(Username, Password) when ?EMPTY_KEY(Username) orelse ?EMPTY_KEY
     {ok, empty};
 add_default_user(Username, Password) ->
     case lookup_user(Username) of
-        [] -> do_add_user(Username, Password, <<"administrator">>);
+        [] -> do_add_user(Username, Password, ?ROLE_SUPERUSER, <<"administrator">>);
         _ -> {ok, default_user_exists}
     end.
 
+%% ensure the `role` is correct when it is directly read from the table
+%% this value in old data is `undefined`
+-dialyzer({no_match, ensure_role/1}).
+ensure_role(undefined) ->
+    ?ROLE_SUPERUSER;
+ensure_role(Role) when is_binary(Role) ->
+    Role.
+
+-if(?EMQX_RELEASE_EDITION == ee).
+legal_role(Role) ->
+    emqx_dashboard_rbac:valid_role(Role).
+
+role(Data) ->
+    emqx_dashboard_rbac:role(Data).
+
+-else.
+
+-dialyzer({no_match, [add_user/4, update_user/3]}).
+
+legal_role(_) ->
+    ok.
+
+role(_) ->
+    ?ROLE_DEFAULT.
+-endif.
+
 -ifdef(TEST).
 -include_lib("eunit/include/eunit.hrl").
 

+ 40 - 12
apps/emqx_dashboard/src/emqx_dashboard_api.erl

@@ -18,6 +18,7 @@
 
 -behaviour(minirest_api).
 
+-include("emqx_dashboard.hrl").
 -include_lib("hocon/include/hoconsc.hrl").
 -include_lib("emqx/include/logger.hrl").
 -include_lib("typerefl/include/types.hrl").
@@ -111,9 +112,9 @@ schema("/users") ->
         post => #{
             tags => [<<"dashboard">>],
             desc => ?DESC(create_user_api),
-            'requestBody' => fields([username, password, description]),
+            'requestBody' => fields([username, password, role, description]),
             responses => #{
-                200 => fields([username, description])
+                200 => fields([username, role, description])
             }
         }
     };
@@ -124,9 +125,9 @@ schema("/users/:username") ->
             tags => [<<"dashboard">>],
             desc => ?DESC(update_user_api),
             parameters => fields([username_in_path]),
-            'requestBody' => fields([description]),
+            'requestBody' => fields([role, description]),
             responses => #{
-                200 => fields([username, description]),
+                200 => fields([username, role, description]),
                 404 => response_schema(404)
             }
         },
@@ -170,7 +171,7 @@ response_schema(404) ->
 fields(user) ->
     fields([username, description]);
 fields(List) ->
-    [field(Key) || Key <- List].
+    [field(Key) || Key <- List, field_filter(Key)].
 
 field(username) ->
     {username,
@@ -203,7 +204,9 @@ field(version) ->
 field(old_pwd) ->
     {old_pwd, mk(binary(), #{desc => ?DESC(old_pwd)})};
 field(new_pwd) ->
-    {new_pwd, mk(binary(), #{desc => ?DESC(new_pwd)})}.
+    {new_pwd, mk(binary(), #{desc => ?DESC(new_pwd)})};
+field(role) ->
+    {role, mk(binary(), #{desc => ?DESC(role), example => ?ROLE_DEFAULT})}.
 
 %% -------------------------------------------------------------------------------------------------
 %% API
@@ -239,19 +242,20 @@ logout(_, #{
     end.
 
 users(get, _Request) ->
-    {200, emqx_dashboard_admin:all_users()};
+    {200, filter_result(emqx_dashboard_admin:all_users())};
 users(post, #{body := Params}) ->
     Desc = maps:get(<<"description">>, Params, <<"">>),
+    Role = maps:get(<<"role">>, Params, ?ROLE_DEFAULT),
     Username = maps:get(<<"username">>, Params),
     Password = maps:get(<<"password">>, Params),
     case ?EMPTY(Username) orelse ?EMPTY(Password) of
         true ->
             {400, ?BAD_REQUEST, <<"Username or password undefined">>};
         false ->
-            case emqx_dashboard_admin:add_user(Username, Password, Desc) of
+            case emqx_dashboard_admin:add_user(Username, Password, Role, Desc) of
                 {ok, Result} ->
                     ?SLOG(info, #{msg => "Create dashboard success", username => Username}),
-                    {200, Result};
+                    {200, filter_result(Result)};
                 {error, Reason} ->
                     ?SLOG(info, #{
                         msg => "Create dashboard failed",
@@ -263,12 +267,15 @@ users(post, #{body := Params}) ->
     end.
 
 user(put, #{bindings := #{username := Username}, body := Params}) ->
+    Role = maps:get(<<"role">>, Params, ?ROLE_DEFAULT),
     Desc = maps:get(<<"description">>, Params),
-    case emqx_dashboard_admin:update_user(Username, Desc) of
+    case emqx_dashboard_admin:update_user(Username, Role, Desc) of
         {ok, Result} ->
-            {200, Result};
+            {200, filter_result(Result)};
+        {error, <<"username_not_found">> = Reason} ->
+            {404, ?USER_NOT_FOUND, Reason};
         {error, Reason} ->
-            {404, ?USER_NOT_FOUND, Reason}
+            {400, ?BAD_REQUEST, Reason}
     end;
 user(delete, #{bindings := #{username := Username}, headers := Headers}) ->
     case Username == emqx_dashboard_admin:default_username() of
@@ -347,3 +354,24 @@ change_pwd(post, #{bindings := #{username := Username}, body := Params}) ->
                     {400, ?BAD_REQUEST, Reason}
             end
     end.
+
+-if(?EMQX_RELEASE_EDITION == ee).
+field_filter(_) ->
+    true.
+
+filter_result(Result) ->
+    Result.
+
+-else.
+
+field_filter(role) ->
+    false;
+field_filter(_) ->
+    true.
+
+filter_result(Result) when is_list(Result) ->
+    lists:map(fun filter_result/1, Result);
+filter_result(Result) ->
+    maps:without([role], Result).
+
+-endif.

+ 48 - 17
apps/emqx_dashboard/src/emqx_dashboard_cli.erl

@@ -16,6 +16,8 @@
 
 -module(emqx_dashboard_cli).
 
+-include("emqx_dashboard.hrl").
+
 -export([
     load/0,
     admins/1,
@@ -25,15 +27,6 @@
 load() ->
     emqx_ctl:register_command(admins, {?MODULE, admins}, []).
 
-admins(["add", Username, Password]) ->
-    admins(["add", Username, Password, ""]);
-admins(["add", Username, Password, Desc]) ->
-    case emqx_dashboard_admin:add_user(bin(Username), bin(Password), bin(Desc)) of
-        {ok, _} ->
-            emqx_ctl:print("ok~n");
-        {error, Reason} ->
-            print_error(Reason)
-    end;
 admins(["passwd", Username, Password]) ->
     case emqx_dashboard_admin:change_password(bin(Username), bin(Password)) of
         {ok, _} ->
@@ -48,14 +41,8 @@ admins(["del", Username]) ->
         {error, Reason} ->
             print_error(Reason)
     end;
-admins(_) ->
-    emqx_ctl:usage(
-        [
-            {"admins add <Username> <Password> <Description>", "Add dashboard user"},
-            {"admins passwd <Username> <Password>", "Reset dashboard user password"},
-            {"admins del <Username>", "Delete dashboard user"}
-        ]
-    ).
+admins(Args) ->
+    inner_admins(Args).
 
 unload() ->
     emqx_ctl:unregister_command(admins).
@@ -67,3 +54,47 @@ print_error(Reason) when is_binary(Reason) ->
 %% Maybe has more types of error, but there is only binary now. So close it for dialyzer.
 % print_error(Reason) ->
 %     emqx_ctl:print("Error: ~p~n", [Reason]).
+
+-if(?EMQX_RELEASE_EDITION == ee).
+usage() ->
+    [
+        {"admins add <Username> <Password> <Role> <Description>", "Add dashboard user"},
+        {"admins passwd <Username> <Password>", "Reset dashboard user password"},
+        {"admins del <Username>", "Delete dashboard user"}
+    ].
+
+inner_admins(["add", Username, Password]) ->
+    inner_admins(["add", Username, Password, ?ROLE_SUPERUSER]);
+inner_admins(["add", Username, Password, Role]) ->
+    inner_admins(["add", Username, Password, Role, ""]);
+inner_admins(["add", Username, Password, Role, Desc]) ->
+    case emqx_dashboard_admin:add_user(bin(Username), bin(Password), bin(Role), bin(Desc)) of
+        {ok, _} ->
+            emqx_ctl:print("ok~n");
+        {error, Reason} ->
+            print_error(Reason)
+    end;
+inner_admins(_) ->
+    emqx_ctl:usage(usage()).
+-else.
+
+usage() ->
+    [
+        {"admins add <Username> <Password> <Description>", "Add dashboard user"},
+        {"admins passwd <Username> <Password>", "Reset dashboard user password"},
+        {"admins del <Username>", "Delete dashboard user"}
+    ].
+
+inner_admins(["add", Username, Password]) ->
+    inner_admins(["add", Username, Password, ""]);
+inner_admins(["add", Username, Password, Desc]) ->
+    case emqx_dashboard_admin:add_user(bin(Username), bin(Password), ?ROLE_SUPERUSER, bin(Desc)) of
+        {ok, _} ->
+            emqx_ctl:print("ok~n");
+        {error, Reason} ->
+            print_error(Reason)
+    end;
+inner_admins(_) ->
+    emqx_ctl:usage(usage()).
+
+-endif.

+ 44 - 20
apps/emqx_dashboard/src/emqx_dashboard_token.erl

@@ -20,7 +20,7 @@
 
 -export([
     sign/2,
-    verify/1,
+    verify/2,
     lookup/1,
     owner/1,
     destroy/1,
@@ -55,14 +55,17 @@
 
 %%--------------------------------------------------------------------
 %% jwt function
--spec sign(Username :: binary(), Password :: binary()) ->
+-spec sign(User :: dashboard_user(), Password :: binary()) ->
     {ok, Token :: binary()} | {error, Reason :: term()}.
-sign(Username, Password) ->
-    do_sign(Username, Password).
+sign(User, Password) ->
+    do_sign(User, Password).
 
--spec verify(Token :: binary()) -> Result :: ok | {error, token_timeout | not_found}.
-verify(Token) ->
-    do_verify(Token).
+-spec verify(_, Token :: binary()) ->
+    Result ::
+        ok
+        | {error, token_timeout | not_found | unauthorized_role}.
+verify(Req, Token) ->
+    do_verify(Req, Token).
 
 -spec destroy(KeyOrKeys :: list() | binary() | #?ADMIN_JWT{}) -> ok.
 destroy([]) ->
@@ -101,7 +104,7 @@ mnesia(boot) ->
 
 %%--------------------------------------------------------------------
 %% jwt apply
-do_sign(Username, Password) ->
+do_sign(#?ADMIN{username = Username} = User, Password) ->
     ExpTime = jwt_expiration_time(),
     Salt = salt(),
     JWK = jwk(Username, Password, Salt),
@@ -114,22 +117,28 @@ do_sign(Username, Password) ->
     },
     Signed = jose_jwt:sign(JWK, JWS, JWT),
     {_, Token} = jose_jws:compact(Signed),
-    JWTRec = format(Token, Username, ExpTime),
+    Role = emqx_dashboard_admin:role(User),
+    JWTRec = format(Token, Username, Role, ExpTime),
     _ = mria:transaction(?DASHBOARD_SHARD, fun mnesia:write/1, [JWTRec]),
     {ok, Token}.
 
-do_verify(Token) ->
+do_verify(Req, Token) ->
     case lookup(Token) of
-        {ok, JWT = #?ADMIN_JWT{exptime = ExpTime}} ->
+        {ok, JWT = #?ADMIN_JWT{exptime = ExpTime, extra = Extra}} ->
             case ExpTime > erlang:system_time(millisecond) of
                 true ->
-                    NewJWT = JWT#?ADMIN_JWT{exptime = jwt_expiration_time()},
-                    {atomic, Res} = mria:transaction(
-                        ?DASHBOARD_SHARD,
-                        fun mnesia:write/1,
-                        [NewJWT]
-                    ),
-                    Res;
+                    case check_rbac(Req, Extra) of
+                        true ->
+                            NewJWT = JWT#?ADMIN_JWT{exptime = jwt_expiration_time()},
+                            {atomic, Res} = mria:transaction(
+                                ?DASHBOARD_SHARD,
+                                fun mnesia:write/1,
+                                [NewJWT]
+                            ),
+                            Res;
+                        _ ->
+                            {error, unauthorized_role}
+                    end;
                 _ ->
                     {error, token_timeout}
             end;
@@ -183,11 +192,12 @@ jwt_expiration_time() ->
 token_ttl() ->
     emqx_conf:get([dashboard, token_expired_time], ?EXPTIME).
 
-format(Token, Username, ExpTime) ->
+format(Token, Username, Role, ExpTime) ->
     #?ADMIN_JWT{
         token = Token,
         username = Username,
-        exptime = ExpTime
+        exptime = ExpTime,
+        extra = #{role => Role}
     }.
 
 %%--------------------------------------------------------------------
@@ -234,3 +244,17 @@ clean_expired_jwt(Now) ->
         fun() -> mnesia:select(?TAB, Spec) end
     ),
     ok = destroy(JWTList).
+
+-if(?EMQX_RELEASE_EDITION == ee).
+check_rbac(Req, Extra) ->
+    emqx_dashboard_rbac:check_rbac(Req, Extra).
+
+-else.
+
+-dialyzer({nowarn_function, [check_rbac/2]}).
+-dialyzer({no_match, [do_verify/2]}).
+
+check_rbac(_Req, _Extra) ->
+    true.
+
+-endif.

+ 45 - 17
apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl

@@ -67,7 +67,9 @@ end_per_suite(_Config) ->
 
 t_overview(_) ->
     mnesia:clear_table(?ADMIN),
-    emqx_dashboard_admin:add_user(<<"admin">>, <<"public_www1">>, <<"simple_description">>),
+    emqx_dashboard_admin:add_user(
+        <<"admin">>, <<"public_www1">>, ?ROLE_SUPERUSER, <<"simple_description">>
+    ),
     Headers = auth_header_(<<"admin">>, <<"public_www1">>),
     [
         {ok, _} = request_dashboard(get, api_path([Overview]), Headers)
@@ -77,8 +79,12 @@ t_overview(_) ->
 t_admins_add_delete(_) ->
     mnesia:clear_table(?ADMIN),
     Desc = <<"simple description">>,
-    {ok, _} = emqx_dashboard_admin:add_user(<<"username">>, <<"password_0">>, Desc),
-    {ok, _} = emqx_dashboard_admin:add_user(<<"username1">>, <<"password1">>, Desc),
+    {ok, _} = emqx_dashboard_admin:add_user(
+        <<"username">>, <<"password_0">>, ?ROLE_SUPERUSER, Desc
+    ),
+    {ok, _} = emqx_dashboard_admin:add_user(
+        <<"username1">>, <<"password1">>, ?ROLE_SUPERUSER, Desc
+    ),
     Admins = emqx_dashboard_admin:all_users(),
     ?assertEqual(2, length(Admins)),
     {ok, _} = emqx_dashboard_admin:remove_user(<<"username1">>),
@@ -95,7 +101,7 @@ t_admins_add_delete(_) ->
 t_admin_delete_self_failed(_) ->
     mnesia:clear_table(?ADMIN),
     Desc = <<"simple description">>,
-    _ = emqx_dashboard_admin:add_user(<<"username1">>, <<"password_1">>, Desc),
+    _ = emqx_dashboard_admin:add_user(<<"username1">>, <<"password_1">>, ?ROLE_SUPERUSER, Desc),
     Admins = emqx_dashboard_admin:all_users(),
     ?assertEqual(1, length(Admins)),
     Header = auth_header_(<<"username1">>, <<"password_1">>),
@@ -109,23 +115,34 @@ t_rest_api(_Config) ->
     mnesia:clear_table(?ADMIN),
     Desc = <<"administrator">>,
     Password = <<"public_www1">>,
-    emqx_dashboard_admin:add_user(<<"admin">>, Password, Desc),
+    emqx_dashboard_admin:add_user(<<"admin">>, Password, ?ROLE_SUPERUSER, Desc),
     {ok, 200, Res0} = http_get(["users"]),
     ?assertEqual(
         [
-            #{
+            filter_req(#{
                 <<"username">> => <<"admin">>,
-                <<"description">> => <<"administrator">>
-            }
+                <<"description">> => <<"administrator">>,
+                <<"role">> => ?ROLE_SUPERUSER
+            })
         ],
         get_http_data(Res0)
     ),
-    {ok, 200, _} = http_put(["users", "admin"], #{<<"description">> => <<"a_new_description">>}),
-    {ok, 200, _} = http_post(["users"], #{
-        <<"username">> => <<"usera">>,
-        <<"password">> => <<"passwd_01234">>,
-        <<"description">> => Desc
-    }),
+    {ok, 200, _} = http_put(
+        ["users", "admin"],
+        filter_req(#{
+            <<"role">> => ?ROLE_SUPERUSER,
+            <<"description">> => <<"a_new_description">>
+        })
+    ),
+    {ok, 200, _} = http_post(
+        ["users"],
+        filter_req(#{
+            <<"username">> => <<"usera">>,
+            <<"password">> => <<"passwd_01234">>,
+            <<"role">> => ?ROLE_SUPERUSER,
+            <<"description">> => Desc
+        })
+    ),
     {ok, 204, _} = http_delete(["users", "usera"]),
     {ok, 404, _} = http_delete(["users", "usera"]),
     {ok, 204, _} = http_post(
@@ -136,7 +153,7 @@ t_rest_api(_Config) ->
         }
     ),
     mnesia:clear_table(?ADMIN),
-    emqx_dashboard_admin:add_user(<<"admin">>, Password, <<"administrator">>),
+    emqx_dashboard_admin:add_user(<<"admin">>, Password, ?ROLE_SUPERUSER, <<"administrator">>),
     ok.
 
 t_swagger_json(_Config) ->
@@ -180,7 +197,7 @@ t_cli(_Config) ->
 t_lookup_by_username_jwt(_Config) ->
     User = bin(["user-", integer_to_list(random_num())]),
     Pwd = bin("t_password" ++ integer_to_list(random_num())),
-    emqx_dashboard_token:sign(User, Pwd),
+    emqx_dashboard_token:sign(#?ADMIN{username = User}, Pwd),
     ?assertMatch(
         [#?ADMIN_JWT{username = User}],
         emqx_dashboard_token:lookup_by_username(User)
@@ -194,7 +211,7 @@ t_lookup_by_username_jwt(_Config) ->
 t_clean_expired_jwt(_Config) ->
     User = bin(["user-", integer_to_list(random_num())]),
     Pwd = bin("t_password" ++ integer_to_list(random_num())),
-    emqx_dashboard_token:sign(User, Pwd),
+    emqx_dashboard_token:sign(#?ADMIN{username = User}, Pwd),
     [#?ADMIN_JWT{username = User, exptime = ExpTime}] =
         emqx_dashboard_token:lookup_by_username(User),
     ok = emqx_dashboard_token:clean_expired_jwt(_Now1 = ExpTime),
@@ -261,3 +278,14 @@ api_path(Parts) ->
 json(Data) ->
     {ok, Jsx} = emqx_utils_json:safe_decode(Data, [return_maps]),
     Jsx.
+
+-if(?EMQX_RELEASE_EDITION == ee).
+filter_req(Req) ->
+    Req.
+
+-else.
+
+filter_req(Req) ->
+    maps:without([role, <<"role">>], Req).
+
+-endif.

+ 30 - 17
apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl

@@ -42,8 +42,8 @@ t_check_user(_) ->
     BadPassword = <<"public_bad">>,
     EmptyUsername = <<>>,
     EmptyPassword = <<>>,
-    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, <<"desc">>),
-    ok = emqx_dashboard_admin:check(Username, Password),
+    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, ?ROLE_SUPERUSER, <<"desc">>),
+    {ok, _} = emqx_dashboard_admin:check(Username, Password),
     {error, <<"password_error">>} = emqx_dashboard_admin:check(Username, BadPassword),
     {error, <<"username_not_found">>} = emqx_dashboard_admin:check(BadUsername, Password),
     {error, <<"username_not_found">>} = emqx_dashboard_admin:check(BadUsername, BadPassword),
@@ -61,19 +61,23 @@ t_add_user(_) ->
     BadAddUser = <<"***add_user_bad">>,
 
     %% add success. not return password
-    {ok, NewUser} = emqx_dashboard_admin:add_user(AddUser, AddPassword, AddDescription),
+    {ok, NewUser} = emqx_dashboard_admin:add_user(
+        AddUser, AddPassword, ?ROLE_SUPERUSER, AddDescription
+    ),
     AddUser = maps:get(username, NewUser),
     AddDescription = maps:get(description, NewUser),
     false = maps:is_key(password, NewUser),
 
     %% add again
     {error, <<"username_already_exist">>} =
-        emqx_dashboard_admin:add_user(AddUser, AddPassword, AddDescription),
+        emqx_dashboard_admin:add_user(AddUser, AddPassword, ?ROLE_SUPERUSER, AddDescription),
 
     %% add bad username
     BadNameError =
         <<"Bad Username. Only upper and lower case letters, numbers and underscores are supported">>,
-    {error, BadNameError} = emqx_dashboard_admin:add_user(BadAddUser, AddPassword, AddDescription),
+    {error, BadNameError} = emqx_dashboard_admin:add_user(
+        BadAddUser, AddPassword, ?ROLE_SUPERUSER, AddDescription
+    ),
     ok.
 
 t_lookup_user(_) ->
@@ -84,7 +88,9 @@ t_lookup_user(_) ->
     BadLookupUser = <<"***lookup_user_bad">>,
 
     {ok, _} =
-        emqx_dashboard_admin:add_user(LookupUser, LookupPassword, LookupDescription),
+        emqx_dashboard_admin:add_user(
+            LookupUser, LookupPassword, ?ROLE_SUPERUSER, LookupDescription
+        ),
     %% lookup success. not return password
     [#emqx_admin{username = LookupUser, description = LookupDescription}] =
         emqx_dashboard_admin:lookup_user(LookupUser),
@@ -95,7 +101,7 @@ t_lookup_user(_) ->
 t_all_users(_) ->
     Username = <<"admin_all">>,
     Password = <<"public_2">>,
-    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, <<"desc">>),
+    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, ?ROLE_SUPERUSER, <<"desc">>),
     All = emqx_dashboard_admin:all_users(),
     ?assert(erlang:length(All) >= 1),
     ok.
@@ -108,7 +114,9 @@ t_delete_user(_) ->
     DeleteBadUser = <<"delete_user_bad">>,
 
     {ok, _NewUser} =
-        emqx_dashboard_admin:add_user(DeleteUser, DeletePassword, DeleteDescription),
+        emqx_dashboard_admin:add_user(
+            DeleteUser, DeletePassword, ?ROLE_SUPERUSER, DeleteDescription
+        ),
     {ok, ok} = emqx_dashboard_admin:remove_user(DeleteUser),
     %% remove again
     {error, <<"username_not_found">>} = emqx_dashboard_admin:remove_user(DeleteUser),
@@ -124,13 +132,17 @@ t_update_user(_) ->
 
     BadUpdateUser = <<"update_user_bad">>,
 
-    {ok, _} = emqx_dashboard_admin:add_user(UpdateUser, UpdatePassword, UpdateDescription),
+    {ok, _} = emqx_dashboard_admin:add_user(
+        UpdateUser, UpdatePassword, ?ROLE_SUPERUSER, UpdateDescription
+    ),
     {ok, NewUserInfo} =
-        emqx_dashboard_admin:update_user(UpdateUser, NewDesc),
+        emqx_dashboard_admin:update_user(UpdateUser, ?ROLE_SUPERUSER, NewDesc),
     UpdateUser = maps:get(username, NewUserInfo),
     NewDesc = maps:get(description, NewUserInfo),
 
-    {error, <<"username_not_found">>} = emqx_dashboard_admin:update_user(BadUpdateUser, NewDesc),
+    {error, <<"username_not_found">>} = emqx_dashboard_admin:update_user(
+        BadUpdateUser, ?ROLE_SUPERUSER, NewDesc
+    ),
     ok.
 
 t_change_password(_) ->
@@ -143,7 +155,7 @@ t_change_password(_) ->
 
     BadChangeUser = <<"change_user_bad">>,
 
-    {ok, _} = emqx_dashboard_admin:add_user(User, OldPassword, Description),
+    {ok, _} = emqx_dashboard_admin:add_user(User, OldPassword, ?ROLE_SUPERUSER, Description),
 
     {ok, ok} = emqx_dashboard_admin:change_password(User, OldPassword, NewPassword),
     %% change pwd again
@@ -161,17 +173,18 @@ t_clean_token(_) ->
     Username = <<"admin_token">>,
     Password = <<"public_www1">>,
     NewPassword = <<"public_www2">>,
-    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, <<"desc">>),
+    {ok, _} = emqx_dashboard_admin:add_user(Username, Password, ?ROLE_SUPERUSER, <<"desc">>),
     {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
-    ok = emqx_dashboard_admin:verify_token(Token),
+    FakeReq = #{method => <<"GET">>},
+    ok = 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(Token),
+    {error, not_found} = emqx_dashboard_admin:verify_token(FakeReq, Token),
     %% remove user
     {ok, Token2} = emqx_dashboard_admin:sign_token(Username, NewPassword),
-    ok = emqx_dashboard_admin:verify_token(Token2),
+    ok = emqx_dashboard_admin:verify_token(FakeReq, Token2),
     {ok, _} = emqx_dashboard_admin:remove_user(Username),
     timer:sleep(5),
-    {error, not_found} = emqx_dashboard_admin:verify_token(Token2),
+    {error, not_found} = emqx_dashboard_admin:verify_token(FakeReq, Token2),
     ok.

+ 10 - 3
apps/emqx_dashboard/test/emqx_dashboard_api_test_helpers.erl

@@ -24,6 +24,7 @@
     request/2,
     request/3,
     request/4,
+    request/5,
     multipart_formdata_request/3,
     multipart_formdata_request/4,
     host/0,
@@ -73,6 +74,9 @@ request(Method, Url, Body) ->
     request(<<"admin">>, Method, Url, Body).
 
 request(Username, Method, Url, Body) ->
+    request(Username, <<"public">>, Method, Url, Body).
+
+request(Username, Password, Method, Url, Body) ->
     Request =
         case Body of
             [] when
@@ -80,9 +84,10 @@ request(Username, Method, Url, Body) ->
                     Method =:= head orelse Method =:= delete orelse
                     Method =:= trace
             ->
-                {Url, [auth_header(Username)]};
+                {Url, [auth_header(Username, Password)]};
             _ ->
-                {Url, [auth_header(Username)], "application/json", emqx_utils_json:encode(Body)}
+                {Url, [auth_header(Username, Password)], "application/json",
+                    emqx_utils_json:encode(Body)}
         end,
     ct:pal("Method: ~p, Request: ~p", [Method, Request]),
     case httpc:request(Method, Request, [], [{body_format, binary}]) of
@@ -108,7 +113,9 @@ uri(Host, Parts) when is_list(Host), is_list(Parts) ->
     Host ++ "/" ++ to_list(filename:join([?BASE_PATH, ?API_VERSION | NParts])).
 
 auth_header(Username) ->
-    Password = <<"public">>,
+    auth_header(Username, <<"public">>).
+
+auth_header(Username, Password) ->
     {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password),
     {"Authorization", "Bearer " ++ binary_to_list(Token)}.
 

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

+ 15 - 0
apps/emqx_dashboard_rbac/README.md

@@ -0,0 +1,15 @@
+# Dashboard Role-Based Access Control
+
+RBAC (Role-Based Access Control) is a common access control model for managing user access to systems, applications or resources.
+In the RBAC model, access permissions are assigned and managed based on user roles instead of being directly associated with individual users,
+making management and usage simpler.
+
+This application houses the RBAC feature for Dashboard.
+
+## Contributing
+
+Please see our [contributing.md](../../CONTRIBUTING.md).
+
+## License
+
+See [APL](../../APL.txt).

+ 1 - 0
apps/emqx_dashboard_rbac/docker-ct

@@ -0,0 +1 @@
+

+ 6 - 0
apps/emqx_dashboard_rbac/rebar.config

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

+ 14 - 0
apps/emqx_dashboard_rbac/src/emqx_dashboard_rbac.app.src

@@ -0,0 +1,14 @@
+{application, emqx_dashboard_rbac, [
+    {description, "EMQX Dashboard RBAC"},
+    {vsn, "0.1.0"},
+    {registered, []},
+    {applications, [
+        kernel,
+        stdlib,
+        emqx_dashboard
+    ]},
+    {env, []},
+    {modules, []},
+
+    {links, []}
+]}.

+ 46 - 0
apps/emqx_dashboard_rbac/src/emqx_dashboard_rbac.erl

@@ -0,0 +1,46 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_rbac).
+
+-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
+
+-export([check_rbac/2, role/1, valid_role/1]).
+
+-dialyzer({nowarn_function, role/1}).
+%%=====================================================================
+%% API
+check_rbac(Req, Extra) ->
+    Method = cowboy_req:method(Req),
+    Role = role(Extra),
+    check_rbac_with_method(Role, Method).
+
+%% For compatibility
+role(#?ADMIN{role = undefined}) ->
+    ?ROLE_SUPERUSER;
+role(#?ADMIN{role = Role}) ->
+    Role;
+%% For compatibility
+role([]) ->
+    ?ROLE_SUPERUSER;
+role(#{role := Role}) ->
+    Role.
+
+valid_role(Role) ->
+    case lists:member(Role, role_list()) of
+        true ->
+            ok;
+        _ ->
+            {error, <<"Role does not exist">>}
+    end.
+%% ===================================================================
+check_rbac_with_method(?ROLE_SUPERUSER, _) ->
+    true;
+check_rbac_with_method(?ROLE_VIEWER, <<"GET">>) ->
+    true;
+check_rbac_with_method(_, _) ->
+    false.
+
+role_list() ->
+    [?ROLE_VIEWER, ?ROLE_SUPERUSER].

+ 157 - 0
apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl

@@ -0,0 +1,157 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_dashboard_rbac_SUITE).
+
+-compile(nowarn_export_all).
+-compile(export_all).
+
+-include("emqx_dashboard.hrl").
+-include_lib("eunit/include/eunit.hrl").
+
+-import(emqx_dashboard_api_test_helpers, [request/4, uri/1]).
+
+-define(DEFAULT_SUPERUSER, <<"admin_user">>).
+-define(DEFAULT_SUPERUSER_PASS, <<"admin_password">>).
+-define(ADD_DESCRIPTION, <<>>).
+
+all() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+init_per_suite(Config) ->
+    emqx_mgmt_api_test_util:init_suite([emqx_conf]),
+    Config.
+
+end_per_suite(_Config) ->
+    emqx_mgmt_api_test_util:end_suite([emqx_conf]).
+
+end_per_testcase(_, _Config) ->
+    All = emqx_dashboard_admin:all_users(),
+    [emqx_dashboard_admin:remove_user(Name) || #{username := Name} <- All].
+
+t_create_bad_role(_) ->
+    ?assertEqual(
+        {error, <<"Role does not exist">>},
+        emqx_dashboard_admin:add_user(
+            ?DEFAULT_SUPERUSER,
+            ?DEFAULT_SUPERUSER_PASS,
+            <<"bad_role">>,
+            ?ADD_DESCRIPTION
+        )
+    ).
+
+t_permission(_) ->
+    add_default_superuser(),
+
+    ViewerUser = <<"viewer_user">>,
+    ViewerPassword = <<"add_password">>,
+
+    %% add by superuser
+    {ok, 200, Payload} = emqx_dashboard_api_test_helpers:request(
+        ?DEFAULT_SUPERUSER,
+        ?DEFAULT_SUPERUSER_PASS,
+        post,
+        uri([users]),
+        #{
+            username => ViewerUser,
+            password => ViewerPassword,
+            role => ?ROLE_VIEWER,
+            description => ?ADD_DESCRIPTION
+        }
+    ),
+
+    ?assertEqual(
+        #{
+            <<"username">> => ViewerUser,
+            <<"role">> => ?ROLE_VIEWER,
+            <<"description">> => ?ADD_DESCRIPTION
+        },
+        emqx_utils_json:decode(Payload, [return_maps])
+    ),
+
+    %% add by viewer
+    ?assertMatch(
+        {ok, 403, _},
+        emqx_dashboard_api_test_helpers:request(
+            ViewerUser,
+            ViewerPassword,
+            post,
+            uri([users]),
+            #{
+                username => ViewerUser,
+                password => ViewerPassword,
+                role => ?ROLE_VIEWER,
+                description => ?ADD_DESCRIPTION
+            }
+        )
+    ),
+
+    ok.
+
+t_update_role(_) ->
+    add_default_superuser(),
+
+    %% update role by superuser
+    {ok, 200, Payload} = emqx_dashboard_api_test_helpers:request(
+        ?DEFAULT_SUPERUSER,
+        ?DEFAULT_SUPERUSER_PASS,
+        put,
+        uri([users, ?DEFAULT_SUPERUSER]),
+        #{
+            role => ?ROLE_VIEWER,
+            description => ?ADD_DESCRIPTION
+        }
+    ),
+
+    ?assertEqual(
+        #{
+            <<"username">> => ?DEFAULT_SUPERUSER,
+            <<"role">> => ?ROLE_VIEWER,
+            <<"description">> => ?ADD_DESCRIPTION
+        },
+        emqx_utils_json:decode(Payload, [return_maps])
+    ),
+
+    %% update role by viewer
+    ?assertMatch(
+        {ok, 403, _},
+        emqx_dashboard_api_test_helpers:request(
+            ?DEFAULT_SUPERUSER,
+            ?DEFAULT_SUPERUSER_PASS,
+            put,
+            uri([users, ?DEFAULT_SUPERUSER]),
+            #{
+                role => ?ROLE_SUPERUSER,
+                description => ?ADD_DESCRIPTION
+            }
+        )
+    ),
+    ok.
+
+t_clean_token(_) ->
+    Username = <<"admin_token">>,
+    Password = <<"public_www1">>,
+    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 = emqx_dashboard_admin:verify_token(FakeReq, Token),
+    %% change description
+    {ok, _} = emqx_dashboard_admin:update_user(Username, ?ROLE_SUPERUSER, NewDesc),
+    timer:sleep(5),
+    ok = emqx_dashboard_admin:verify_token(FakeReq, Token),
+    %% change role
+    {ok, _} = emqx_dashboard_admin:update_user(Username, ?ROLE_VIEWER, NewDesc),
+    timer:sleep(5),
+    {error, not_found} = emqx_dashboard_admin:verify_token(FakeReq, Token),
+    ok.
+
+add_default_superuser() ->
+    {ok, _NewUser} = emqx_dashboard_admin:add_user(
+        ?DEFAULT_SUPERUSER,
+        ?DEFAULT_SUPERUSER_PASS,
+        ?ROLE_SUPERUSER,
+        ?ADD_DESCRIPTION
+    ).

+ 2 - 1
apps/emqx_machine/priv/reboot_lists.eterm

@@ -114,7 +114,8 @@
             emqx_node_rebalance,
             emqx_ft,
             emqx_ldap,
-            emqx_gcp_device
+            emqx_gcp_device,
+            emqx_dashboard_rbac
         ],
     %% must always be of type `load'
     ce_business_apps =>

+ 3 - 2
apps/emqx_management/test/emqx_mgmt_data_backup_SUITE.erl

@@ -22,6 +22,7 @@
 -include_lib("common_test/include/ct.hrl").
 -include_lib("snabbkaffe/include/snabbkaffe.hrl").
 
+-define(ROLE_SUPERUSER, <<"superuser">>).
 -define(BOOTSTRAP_BACKUP, "emqx-export-test-bootstrap-ce.tar.gz").
 
 all() ->
@@ -297,7 +298,7 @@ t_import_on_cluster(Config) ->
 t_verify_imported_mnesia_tab_on_cluster(Config) ->
     UsersToExport = users(<<"user_to_export_">>),
     UsersBeforeImport = users(<<"user_before_import_">>),
-    [{ok, _} = emqx_dashboard_admin:add_user(U, U, U) || U <- UsersToExport],
+    [{ok, _} = emqx_dashboard_admin:add_user(U, U, ?ROLE_SUPERUSER, U) || U <- UsersToExport],
     {ok, #{filename := FileName}} = emqx_mgmt_data_backup:export(),
     {ok, Cwd} = file:get_cwd(),
     AbsFilePath = filename:join(Cwd, FileName),
@@ -305,7 +306,7 @@ t_verify_imported_mnesia_tab_on_cluster(Config) ->
     [CoreNode1, CoreNode2, ReplicantNode] = ?config(cluster, Config),
 
     [
-        {ok, _} = rpc:call(CoreNode1, emqx_dashboard_admin, add_user, [U, U, U])
+        {ok, _} = rpc:call(CoreNode1, emqx_dashboard_admin, add_user, [U, U, ?ROLE_SUPERUSER, U])
      || U <- UsersBeforeImport
     ],
 

+ 9 - 0
changes/ee/feat-11610.en.md

@@ -0,0 +1,9 @@
+Implemented a preliminary Role-Based Access Control for the Dashboard.
+
+In this version, there are two predefined roles:
+- superuser
+
+  This role could access all resources.
+- viewer
+
+  This role can only view resources and data, corresponding to all GET requests in the REST API.

+ 3 - 2
mix.exs

@@ -58,7 +58,7 @@ defmodule EMQXUmbrella.MixProject do
       {:ekka, github: "emqx/ekka", tag: "0.15.13", override: true},
       {:gen_rpc, github: "emqx/gen_rpc", tag: "2.8.1", override: true},
       {:grpc, github: "emqx/grpc-erl", tag: "0.6.8", override: true},
-      {:minirest, github: "emqx/minirest", tag: "1.3.11", override: true},
+      {:minirest, github: "emqx/minirest", tag: "1.3.12", override: true},
       {:ecpool, github: "emqx/ecpool", tag: "0.5.4", override: true},
       {:replayq, github: "emqx/replayq", tag: "0.3.7", override: true},
       {:pbkdf2, github: "emqx/erlang-pbkdf2", tag: "2.0.4", override: true},
@@ -226,7 +226,8 @@ defmodule EMQXUmbrella.MixProject do
       :emqx_bridge_kinesis,
       :emqx_bridge_azure_event_hub,
       :emqx_ldap,
-      :emqx_gcp_device
+      :emqx_gcp_device,
+      :emqx_dashboard_rbac
     ])
   end
 

+ 1 - 1
rebar.config

@@ -65,7 +65,7 @@
     , {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.15.13"}}}
     , {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.8.1"}}}
     , {grpc, {git, "https://github.com/emqx/grpc-erl", {tag, "0.6.8"}}}
-    , {minirest, {git, "https://github.com/emqx/minirest", {tag, "1.3.11"}}}
+    , {minirest, {git, "https://github.com/emqx/minirest", {tag, "1.3.12"}}}
     , {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.4"}}}
     , {replayq, {git, "https://github.com/emqx/replayq.git", {tag, "0.3.7"}}}
     , {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}}

+ 1 - 0
rebar.config.erl

@@ -109,6 +109,7 @@ is_community_umbrella_app("apps/emqx_bridge_kinesis") -> false;
 is_community_umbrella_app("apps/emqx_bridge_azure_event_hub") -> false;
 is_community_umbrella_app("apps/emqx_ldap") -> false;
 is_community_umbrella_app("apps/emqx_gcp_device") -> false;
+is_community_umbrella_app("apps/emqx_dashboard_rbac") -> false;
 is_community_umbrella_app(_) -> true.
 
 is_jq_supported() ->