diff --git a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl index 7cfa1a918..c0fe63523 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_admin.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_admin.erl @@ -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 @@ -228,7 +229,20 @@ remove_user(Username) when is_binary(Username) -> update_user(Username, Role, Desc) when is_binary(Username) -> case legal_role(Role) of ok -> - return(mria:transaction(?DASHBOARD_SHARD, fun update_user_/3, [Username, Role, Desc])); + 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. @@ -258,7 +272,7 @@ update_user_(Username, Role, Desc) -> mnesia:abort(<<"username_not_found">>); [Admin] -> mnesia:write(Admin#?ADMIN{role = Role, description = Desc}), - #{username => Username, role => Role, description => Desc} + {role(Admin) =:= Role, #{username => Username, role => Role, description => Desc}} end. change_password(Username, OldPasswd, NewPasswd) when is_binary(Username) -> @@ -381,8 +395,9 @@ add_default_user(Username, Password) -> _ -> {ok, default_user_exists} end. -%% ensure the `role` is correct when it directly read from the table +%% 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) -> @@ -392,6 +407,9 @@ ensure_role(Role) when is_binary(Role) -> legal_role(Role) -> emqx_dashboard_rbac:legal_role(Role). +role(Data) -> + emqx_dashboard_rbac:role(Data). + -else. -dialyzer({no_match, [add_user/4, update_user/3]}). @@ -399,6 +417,8 @@ legal_role(Role) -> legal_role(_) -> ok. +role(_) -> + ?ROLE_DEFAULT. -endif. -ifdef(TEST). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_token.erl b/apps/emqx_dashboard/src/emqx_dashboard_token.erl index 50d076ad4..f71df77bd 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_token.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_token.erl @@ -117,7 +117,7 @@ do_sign(#?ADMIN{username = Username} = User, Password) -> }, Signed = jose_jwt:sign(JWK, JWS, JWT), {_, Token} = jose_jws:compact(Signed), - Role = role(User), + Role = emqx_dashboard_admin:role(User), JWTRec = format(Token, Username, Role, ExpTime), _ = mria:transaction(?DASHBOARD_SHARD, fun mnesia:write/1, [JWTRec]), {ok, Token}. @@ -249,9 +249,6 @@ clean_expired_jwt(Now) -> check_rbac(Req, Extra) -> emqx_dashboard_rbac:check_rbac(Req, Extra). -role(Data) -> - emqx_dashboard_rbac:role(Data). - -else. -dialyzer({nowarn_function, [check_rbac/2]}). @@ -260,7 +257,4 @@ role(Data) -> check_rbac(_Req, _Extra) -> true. -role(_) -> - ?ROLE_DEFAULT. - -endif. diff --git a/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl b/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl index 7432dce2b..141044836 100644 --- a/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl +++ b/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl @@ -119,10 +119,11 @@ t_rest_api(_Config) -> {ok, 200, Res0} = http_get(["users"]), ?assertEqual( [ - #{ + filter_req(#{ <<"username">> => <<"admin">>, - <<"description">> => <<"administrator">> - } + <<"description">> => <<"administrator">>, + <<"role">> => ?ROLE_SUPERUSER + }) ], get_http_data(Res0) ), diff --git a/apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl b/apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl index dbd19c00d..45684012b 100644 --- a/apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl +++ b/apps/emqx_dashboard/test/emqx_dashboard_admin_SUITE.erl @@ -175,7 +175,7 @@ t_clean_token(_) -> 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">>}, + FakeReq = #{method => <<"GET">>}, ok = emqx_dashboard_admin:verify_token(FakeReq, Token), %% change password {ok, _} = emqx_dashboard_admin:change_password(Username, Password, NewPassword), diff --git a/apps/emqx_dashboard/test/emqx_dashboard_api_test_helpers.erl b/apps/emqx_dashboard/test/emqx_dashboard_api_test_helpers.erl index 5e8c61e15..cf022d65d 100644 --- a/apps/emqx_dashboard/test/emqx_dashboard_api_test_helpers.erl +++ b/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)}. diff --git a/apps/emqx_dashboard_rbac/.gitignore b/apps/emqx_dashboard_rbac/.gitignore deleted file mode 100644 index 3b0d6b553..000000000 --- a/apps/emqx_dashboard_rbac/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -src/emqx_ldap_filter_lexer.erl -src/emqx_ldap_filter_parser.erl diff --git a/apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl b/apps/emqx_dashboard_rbac/test/emqx_dashboard_rbac_SUITE.erl new file mode 100644 index 000000000..607cb710d --- /dev/null +++ b/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 + ). diff --git a/changes/ee/feat-11610.en.md b/changes/ee/feat-11610.en.md new file mode 100644 index 000000000..0d9cae031 --- /dev/null +++ b/changes/ee/feat-11610.en.md @@ -0,0 +1,6 @@ +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 only can access the `GET` resource. diff --git a/mix.exs b/mix.exs index 16753eed1..68365f44c 100644 --- a/mix.exs +++ b/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},