feat(api_key): add RBAC feature for the API key

This commit is contained in:
firest 2023-10-16 14:08:12 +08:00
parent 96c546c187
commit e095de7367
11 changed files with 261 additions and 84 deletions

View File

@ -17,6 +17,7 @@
-module(emqx_common_test_http).
-include_lib("common_test/include/ct.hrl").
-include_lib("emqx_dashboard/include/emqx_dashboard_rbac.hrl").
-export([
request_api/3,
@ -90,7 +91,12 @@ create_default_app() ->
Now = erlang:system_time(second),
ExpiredAt = Now + timer:minutes(10),
emqx_mgmt_auth:create(
?DEFAULT_APP_ID, ?DEFAULT_APP_SECRET, true, ExpiredAt, <<"default app key for test">>
?DEFAULT_APP_ID,
?DEFAULT_APP_SECRET,
true,
ExpiredAt,
<<"default app key for test">>,
?ROLE_API_SUPERUSER
).
delete_default_app() ->

View File

@ -13,16 +13,9 @@
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-define(ADMIN, emqx_admin).
-include("emqx_dashboard_rbac.hrl").
%% 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, <<"administrator">>).
-define(ROLE_DEFAULT, ?ROLE_SUPERUSER).
-define(ADMIN, emqx_admin).
-define(BACKEND_LOCAL, local).
-define(SSO_USERNAME(Backend, Name), {Backend, Name}).

View File

@ -0,0 +1,33 @@
%%--------------------------------------------------------------------
%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%--------------------------------------------------------------------
-ifndef(EMQX_DASHBOARD_RBAC).
-define(EMQX_DASHBOARD_RBAC, true).
%% 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, <<"administrator">>).
-define(ROLE_DEFAULT, ?ROLE_SUPERUSER).
-define(ROLE_API_VIEWER, <<"api_viewer">>).
-define(ROLE_API_SUPERUSER, <<"api_administrator">>).
-define(ROLE_API_PUBLISHER, <<"api_publisher">>).
-define(ROLE_API_DEFAULT, ?ROLE_API_SUPERUSER).
-endif.

View File

@ -251,7 +251,7 @@ listeners() ->
api_key_authorize(Req, Key, Secret) ->
Path = cowboy_req:path(Req),
case emqx_mgmt_auth:authorize(Path, Key, Secret) of
case emqx_mgmt_auth:authorize(Path, Req, Key, Secret) of
ok ->
{ok, #{auth_type => api_key, api_key => Key}};
{error, <<"not_allowed">>} ->
@ -259,6 +259,9 @@ api_key_authorize(Req, Key, Secret) ->
?BAD_API_KEY_OR_SECRET,
<<"Not allowed, Check api_key/api_secret">>
);
{error, unauthorized_role} ->
{403, 'UNAUTHORIZED_ROLE',
<<"This API Key don't have permission to access this resource">>};
{error, _} ->
return_unauthorized(
?BAD_API_KEY_OR_SECRET,

View File

@ -416,7 +416,7 @@ ensure_role(Role) when is_binary(Role) ->
-if(?EMQX_RELEASE_EDITION == ee).
legal_role(Role) ->
emqx_dashboard_rbac:valid_role(Role).
emqx_dashboard_rbac:valid_dashboard_role(Role).
role(Data) ->
emqx_dashboard_rbac:role(Data).
@ -447,8 +447,10 @@ lookup_user(Backend, Username) when is_atom(Backend) ->
-dialyzer({no_match, [add_user/4, update_user/3]}).
legal_role(?ROLE_DEFAULT) ->
ok;
legal_role(_) ->
ok.
{error, <<"Role does not exist">>}.
role(_) ->
?ROLE_DEFAULT.

View File

@ -1,6 +1,6 @@
{application, emqx_dashboard_rbac, [
{description, "EMQX Dashboard RBAC"},
{vsn, "0.1.0"},
{vsn, "0.1.1"},
{registered, []},
{applications, [
kernel,

View File

@ -6,7 +6,12 @@
-include_lib("emqx_dashboard/include/emqx_dashboard.hrl").
-export([check_rbac/2, role/1, valid_role/1]).
-export([
check_rbac/2,
role/1,
valid_dashboard_role/1,
valid_api_role/1
]).
-dialyzer({nowarn_function, role/1}).
%%=====================================================================
@ -31,25 +36,44 @@ role(#?ADMIN{role = Role}) ->
role([]) ->
?ROLE_SUPERUSER;
role(#{role := Role}) ->
Role;
role(Role) when is_binary(Role) ->
Role.
valid_role(Role) ->
case lists:member(Role, role_list()) of
valid_dashboard_role(Role) ->
valid_role(dashboard, Role).
valid_api_role(Role) ->
valid_role(api, Role).
%% ===================================================================
check_rbac(?ROLE_SUPERUSER, _, _) ->
true;
check_rbac(?ROLE_API_SUPERUSER, _, _) ->
true;
check_rbac(?ROLE_VIEWER, <<"GET">>, _) ->
true;
check_rbac(?ROLE_API_VIEWER, <<"GET">>, _) ->
true;
%% this API is a special case
check_rbac(?ROLE_VIEWER, <<"POST">>, <<"/logout">>) ->
true;
check_rbac(?ROLE_API_PUBLISHER, <<"POST">>, <<"/publish">>) ->
true;
check_rbac(?ROLE_API_PUBLISHER, <<"POST">>, <<"/publish/bulk">>) ->
true;
check_rbac(_, _, _) ->
false.
valid_role(Type, Role) ->
case lists:member(Role, role_list(Type)) of
true ->
ok;
_ ->
{error, <<"Role does not exist">>}
end.
%% ===================================================================
check_rbac(?ROLE_SUPERUSER, _, _) ->
true;
check_rbac(?ROLE_VIEWER, <<"GET">>, _) ->
true;
%% this API is a special case
check_rbac(?ROLE_VIEWER, <<"POST">>, <<"/logout">>) ->
true;
check_rbac(_, _, _) ->
false.
role_list() ->
[?ROLE_VIEWER, ?ROLE_SUPERUSER].
role_list(dashboard) ->
[?ROLE_VIEWER, ?ROLE_SUPERUSER];
role_list(api) ->
[?ROLE_API_VIEWER, ?ROLE_API_PUBLISHER, ?ROLE_API_SUPERUSER].

View File

@ -19,6 +19,7 @@
-include_lib("typerefl/include/types.hrl").
-include_lib("hocon/include/hoconsc.hrl").
-include_lib("emqx_dashboard/include/emqx_dashboard_rbac.hrl").
-export([api_spec/0, fields/1, paths/0, schema/1, namespace/0]).
-export([api_key/2, api_key_by_name/2]).
@ -150,7 +151,7 @@ fields(app) ->
)},
{enable, hoconsc:mk(boolean(), #{desc => "Enable/Disable", required => false})},
{expired, hoconsc:mk(boolean(), #{desc => "Expired", required => false})}
];
] ++ app_extend_fields();
fields(name) ->
[
{name,
@ -192,7 +193,8 @@ api_key(post, #{body := App}) ->
} = App,
ExpiredAt = ensure_expired_at(App),
Desc = unicode:characters_to_binary(Desc0, unicode),
case emqx_mgmt_auth:create(Name, Enable, ExpiredAt, Desc) of
Role = maps:get(<<"role">>, App, ?ROLE_API_DEFAULT),
case emqx_mgmt_auth:create(Name, Enable, ExpiredAt, Desc, Role) of
{ok, NewApp} ->
{200, emqx_mgmt_auth:format(NewApp)};
{error, Reason} ->
@ -218,10 +220,38 @@ api_key_by_name(put, #{bindings := #{name := Name}, body := Body}) ->
Enable = maps:get(<<"enable">>, Body, undefined),
ExpiredAt = ensure_expired_at(Body),
Desc = maps:get(<<"desc">>, Body, undefined),
case emqx_mgmt_auth:update(Name, Enable, ExpiredAt, Desc) of
{ok, App} -> {200, emqx_mgmt_auth:format(App)};
{error, not_found} -> {404, ?NOT_FOUND_RESPONSE}
Role = maps:get(<<"role">>, Body, ?ROLE_API_DEFAULT),
case emqx_mgmt_auth:update(Name, Enable, ExpiredAt, Desc, Role) of
{ok, App} ->
{200, emqx_mgmt_auth:format(App)};
{error, not_found} ->
{404, ?NOT_FOUND_RESPONSE};
{error, Reason} ->
{400, #{
code => 'BAD_REQUEST',
message => iolist_to_binary(io_lib:format("~p", [Reason]))
}}
end.
ensure_expired_at(#{<<"expired_at">> := ExpiredAt}) when is_integer(ExpiredAt) -> ExpiredAt;
ensure_expired_at(_) -> infinity.
-if(?EMQX_RELEASE_EDITION == ee).
app_extend_fields() ->
[
{role,
hoconsc:mk(binary(), #{
desc => ?DESC(role),
default => ?ROLE_API_DEFAULT,
example => ?ROLE_API_DEFAULT,
validator => fun emqx_dashboard_rbac:valid_api_role/1
})}
].
-else.
app_extend_fields() ->
[].
-endif.

View File

@ -16,6 +16,7 @@
-module(emqx_mgmt_auth).
-include_lib("emqx/include/emqx.hrl").
-include_lib("emqx/include/logger.hrl").
-include_lib("emqx_dashboard/include/emqx_dashboard_rbac.hrl").
-behaviour(emqx_db_backup).
@ -25,16 +26,16 @@
-behaviour(emqx_config_handler).
-export([
create/4,
create/5,
read/1,
update/4,
update/5,
delete/1,
list/0,
init_bootstrap_file/0,
format/1
]).
-export([authorize/3]).
-export([authorize/4]).
-export([post_config_update/5]).
-export([backup_tables/0]).
@ -48,10 +49,11 @@
]).
-ifdef(TEST).
-export([create/5]).
-export([create/6]).
-endif.
-define(APP, emqx_app).
-type api_user_role() :: binary().
-record(?APP, {
name = <<>> :: binary() | '_',
@ -60,17 +62,21 @@
enable = true :: boolean() | '_',
desc = <<>> :: binary() | '_',
expired_at = 0 :: integer() | undefined | infinity | '_',
created_at = 0 :: integer() | '_'
created_at = 0 :: integer() | '_',
role = ?ROLE_DEFAULT :: api_user_role() | '_',
extra = #{} :: map() | '_'
}).
mnesia(boot) ->
Fields = record_info(fields, ?APP),
ok = mria:create_table(?APP, [
{type, set},
{rlog_shard, ?COMMON_SHARD},
{storage, disc_copies},
{record_name, ?APP},
{attributes, record_info(fields, ?APP)}
]).
{attributes, Fields}
]),
maybe_migrate_table(Fields).
%%--------------------------------------------------------------------
%% Data backup
@ -95,13 +101,13 @@ init_bootstrap_file() ->
?SLOG(debug, #{msg => "init_bootstrap_api_keys_from_file", file => File}),
init_bootstrap_file(File).
create(Name, Enable, ExpiredAt, Desc) ->
create(Name, Enable, ExpiredAt, Desc, Role) ->
ApiSecret = generate_api_secret(),
create(Name, ApiSecret, Enable, ExpiredAt, Desc).
create(Name, ApiSecret, Enable, ExpiredAt, Desc, Role).
create(Name, ApiSecret, Enable, ExpiredAt, Desc) ->
create(Name, ApiSecret, Enable, ExpiredAt, Desc, Role) ->
case mnesia:table_info(?APP, size) < 100 of
true -> create_app(Name, ApiSecret, Enable, ExpiredAt, Desc);
true -> create_app(Name, ApiSecret, Enable, ExpiredAt, Desc, Role);
false -> {error, "Maximum ApiKey"}
end.
@ -111,8 +117,13 @@ read(Name) ->
[] -> {error, not_found}
end.
update(Name, Enable, ExpiredAt, Desc) ->
trans(fun ?MODULE:do_update/4, [Name, Enable, ExpiredAt, Desc]).
update(Name, Enable, ExpiredAt, Desc, Role) ->
case valid_role(Role) of
ok ->
trans(fun ?MODULE:do_update/4, [Name, Enable, ExpiredAt, Desc]);
Error ->
Error
end.
do_update(Name, Enable, ExpiredAt, Desc) ->
case mnesia:read(?APP, Name, write) of
@ -138,37 +149,37 @@ do_delete(Name) ->
[_App] -> mnesia:delete({?APP, Name})
end.
format(App = #{expired_at := ExpiredAt0, created_at := CreateAt}) ->
ExpiredAt =
case ExpiredAt0 of
infinity -> <<"infinity">>;
_ -> emqx_utils_calendar:epoch_to_rfc3339(ExpiredAt0, second)
end,
App#{
expired_at => ExpiredAt,
created_at => emqx_utils_calendar:epoch_to_rfc3339(CreateAt, second)
}.
format(App = #{expired_at := ExpiredAt, created_at := CreateAt}) ->
format_app_extend(App#{
expired_at => format_epoch(ExpiredAt),
created_at => format_epoch(CreateAt)
}).
format_epoch(infinity) ->
<<"infinity">>;
format_epoch(Epoch) ->
emqx_utils_calendar:epoch_to_rfc3339(Epoch, second).
list() ->
to_map(ets:match_object(?APP, #?APP{_ = '_'})).
authorize(<<"/api/v5/users", _/binary>>, _ApiKey, _ApiSecret) ->
authorize(<<"/api/v5/users", _/binary>>, _Req, _ApiKey, _ApiSecret) ->
{error, <<"not_allowed">>};
authorize(<<"/api/v5/api_key", _/binary>>, _ApiKey, _ApiSecret) ->
authorize(<<"/api/v5/api_key", _/binary>>, _Req, _ApiKey, _ApiSecret) ->
{error, <<"not_allowed">>};
authorize(<<"/api/v5/logout", _/binary>>, _ApiKey, _ApiSecret) ->
authorize(<<"/api/v5/logout", _/binary>>, _Req, _ApiKey, _ApiSecret) ->
{error, <<"not_allowed">>};
authorize(_Path, ApiKey, ApiSecret) ->
authorize(_Path, Req, ApiKey, ApiSecret) ->
Now = erlang:system_time(second),
case find_by_api_key(ApiKey) of
{ok, true, ExpiredAt, SecretHash} when ExpiredAt >= Now ->
{ok, true, ExpiredAt, SecretHash, Role} when ExpiredAt >= Now ->
case emqx_dashboard_admin:verify_hash(ApiSecret, SecretHash) of
ok -> ok;
ok -> check_rbac(Req, Role);
error -> {error, "secret_error"}
end;
{ok, true, _ExpiredAt, _SecretHash} ->
{ok, true, _ExpiredAt, _SecretHash, _Role} ->
{error, "secret_expired"};
{ok, false, _ExpiredAt, _SecretHash} ->
{ok, false, _ExpiredAt, _SecretHash, _Role} ->
{error, "secret_disable"};
{error, Reason} ->
{error, Reason}
@ -177,8 +188,12 @@ authorize(_Path, ApiKey, ApiSecret) ->
find_by_api_key(ApiKey) ->
Fun = fun() -> mnesia:match_object(#?APP{api_key = ApiKey, _ = '_'}) end,
case mria:ro_transaction(?COMMON_SHARD, Fun) of
{atomic, [#?APP{api_secret_hash = SecretHash, enable = Enable, expired_at = ExpiredAt}]} ->
{ok, Enable, ExpiredAt, SecretHash};
{atomic, [
#?APP{
api_secret_hash = SecretHash, enable = Enable, expired_at = ExpiredAt, role = Role
}
]} ->
{ok, Enable, ExpiredAt, SecretHash, Role};
_ ->
{error, "not_found"}
end.
@ -202,7 +217,7 @@ to_map(#?APP{name = N, api_key = K, enable = E, expired_at = ET, created_at = CT
is_expired(undefined) -> false;
is_expired(ExpiredTime) -> ExpiredTime < erlang:system_time(second).
create_app(Name, ApiSecret, Enable, ExpiredAt, Desc) ->
create_app(Name, ApiSecret, Enable, ExpiredAt, Desc, Role) ->
App =
#?APP{
name = Name,
@ -211,7 +226,8 @@ create_app(Name, ApiSecret, Enable, ExpiredAt, Desc) ->
desc = Desc,
created_at = erlang:system_time(second),
api_secret_hash = emqx_dashboard_admin:hash(ApiSecret),
api_key = list_to_binary(emqx_utils:gen_id(16))
api_key = list_to_binary(emqx_utils:gen_id(16)),
role = Role
},
case create_app(App) of
{ok, Res} ->
@ -220,8 +236,13 @@ create_app(Name, ApiSecret, Enable, ExpiredAt, Desc) ->
Error
end.
create_app(App = #?APP{api_key = ApiKey, name = Name}) ->
trans(fun ?MODULE:do_create_app/3, [App, ApiKey, Name]).
create_app(App = #?APP{api_key = ApiKey, name = Name, role = Role}) ->
case valid_role(Role) of
ok ->
trans(fun ?MODULE:do_create_app/3, [App, ApiKey, Name]);
Error ->
Error
end.
force_create_app(NamePrefix, App = #?APP{api_key = ApiKey}) ->
trans(fun ?MODULE:do_force_create_app/3, [App, ApiKey, NamePrefix]).
@ -340,3 +361,60 @@ add_bootstrap_file(File, Dev, MP, Line) ->
{error, Reason} ->
throw(#{file => File, line => Line, reason => Reason})
end.
-if(?EMQX_RELEASE_EDITION == ee).
check_rbac(Req, Role) ->
case emqx_dashboard_rbac:check_rbac(Req, Role) of
true ->
ok;
_ ->
{error, unauthorized_role}
end.
format_app_extend(App) ->
App.
valid_role(Role) ->
emqx_dashboard_rbac:valid_api_role(Role).
-else.
check_rbac(_Req, _Role) ->
ok.
format_app_extend(App) ->
maps:remove(role, App).
valid_role(?ROLE_API_DEFAULT) ->
ok;
valid_role(_) ->
{error, <<"Role does not exist">>}.
-endif.
maybe_migrate_table(Fields) ->
case mnesia:table_info(?APP, attributes) =:= Fields of
true ->
ok;
false ->
TransFun = fun(App) ->
case App of
{?APP, Name, Key, Hash, Enable, Desc, ExpiredAt, CreatedAt} ->
#?APP{
name = Name,
api_key = Key,
api_secret_hash = Hash,
enable = Enable,
desc = Desc,
expired_at = ExpiredAt,
created_at = CreatedAt,
role = ?ROLE_API_VIEWER,
extra = #{}
};
#?APP{} ->
App
end
end,
{atomic, ok} = mnesia:transform_table(?APP, TransFun, Fields, ?APP),
ok
end.

View File

@ -41,37 +41,42 @@ t_bootstrap_file(_) ->
File = "./bootstrap_api_keys.txt",
ok = file:write_file(File, Bin),
update_file(File),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-1">>, <<"secret-1">>)),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"secret-2">>)),
?assertMatch({error, _}, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"secret-1">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-1">>, <<"secret-1">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-2">>, <<"secret-2">>)),
?assertMatch({error, _}, auth_authorize(TestPath, <<"test-2">>, <<"secret-1">>)),
%% relaunch to check if the table is changed.
Bin1 = <<"test-1:new-secret-1\ntest-2:new-secret-2">>,
ok = file:write_file(File, Bin1),
update_file(File),
?assertMatch({error, _}, emqx_mgmt_auth:authorize(TestPath, <<"test-1">>, <<"secret-1">>)),
?assertMatch({error, _}, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"secret-2">>)),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-1">>, <<"new-secret-1">>)),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"new-secret-2">>)),
?assertMatch({error, _}, auth_authorize(TestPath, <<"test-1">>, <<"secret-1">>)),
?assertMatch({error, _}, auth_authorize(TestPath, <<"test-2">>, <<"secret-2">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-1">>, <<"new-secret-1">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-2">>, <<"new-secret-2">>)),
%% not error when bootstrap_file is empty
update_file(<<>>),
update_file("./bootstrap_apps_not_exist.txt"),
?assertMatch({error, _}, emqx_mgmt_auth:authorize(TestPath, <<"test-1">>, <<"secret-1">>)),
?assertMatch({error, _}, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"secret-2">>)),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-1">>, <<"new-secret-1">>)),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"new-secret-2">>)),
?assertMatch({error, _}, auth_authorize(TestPath, <<"test-1">>, <<"secret-1">>)),
?assertMatch({error, _}, auth_authorize(TestPath, <<"test-2">>, <<"secret-2">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-1">>, <<"new-secret-1">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-2">>, <<"new-secret-2">>)),
%% bad format
BadBin = <<"test-1:secret-11\ntest-2 secret-12">>,
ok = file:write_file(File, BadBin),
update_file(File),
?assertMatch({error, #{reason := "invalid_format"}}, emqx_mgmt_auth:init_bootstrap_file()),
?assertEqual(ok, emqx_mgmt_auth:authorize(TestPath, <<"test-1">>, <<"secret-11">>)),
?assertMatch({error, _}, emqx_mgmt_auth:authorize(TestPath, <<"test-2">>, <<"secret-12">>)),
?assertEqual(ok, auth_authorize(TestPath, <<"test-1">>, <<"secret-11">>)),
?assertMatch({error, _}, auth_authorize(TestPath, <<"test-2">>, <<"secret-12">>)),
update_file(<<>>),
ok.
auth_authorize(Path, Key, Secret) ->
FakePath = erlang:list_to_binary(emqx_dashboard_swagger:relative_uri("/fake")),
FakeReq = #{method => <<"GET">>, path => FakePath},
emqx_mgmt_auth:authorize(Path, FakeReq, Key, Secret).
update_file(File) ->
?assertMatch({ok, _}, emqx:update_config([<<"api_key">>], #{<<"bootstrap_file">> => File})).

View File

@ -30,4 +30,7 @@ format.desc:
format.label:
"""Unique and format by [a-zA-Z0-9-_]"""
role.desc:
"""Role for this API"""
}