diff --git a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src index 9a3c51002..7fcdda1d3 100644 --- a/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src +++ b/apps/emqx_auth_mnesia/src/emqx_auth_mnesia.app.src @@ -1,7 +1,7 @@ %% -*- mode: erlang -*- {application, emqx_auth_mnesia, [ {description, "EMQX Buitl-in Database Authentication and Authorization"}, - {vsn, "0.1.5"}, + {vsn, "0.1.6"}, {registered, []}, {mod, {emqx_auth_mnesia_app, []}}, {applications, [ diff --git a/apps/emqx_auth_mnesia/src/emqx_authn_mnesia.erl b/apps/emqx_auth_mnesia/src/emqx_authn_mnesia.erl index 0c9631896..028718aa4 100644 --- a/apps/emqx_auth_mnesia/src/emqx_authn_mnesia.erl +++ b/apps/emqx_auth_mnesia/src/emqx_authn_mnesia.erl @@ -116,7 +116,7 @@ create( user_id_type := Type, password_hash_algorithm := Algorithm, user_group := UserGroup - } + } = Config ) -> ok = emqx_authn_password_hashing:init(Algorithm), State = #{ @@ -124,6 +124,7 @@ create( user_id_type => Type, password_hash_algorithm => Algorithm }, + ok = boostrap_user_from_file(Config, State), {ok, State}. update(Config, _State) -> @@ -338,8 +339,24 @@ run_fuzzy_filter( %%------------------------------------------------------------------------------ insert_user(UserGroup, UserID, PasswordHash, Salt, IsSuperuser) -> - UserInfoRecord = user_info_record(UserGroup, UserID, PasswordHash, Salt, IsSuperuser), - insert_user(UserInfoRecord). + UserInfoRecord = + #user_info{user_id = DBUserID} = + user_info_record(UserGroup, UserID, PasswordHash, Salt, IsSuperuser), + case mnesia:read(?TAB, DBUserID, write) of + [] -> + insert_user(UserInfoRecord); + [UserInfoRecord] -> + ok; + [_] -> + ?SLOG(warning, #{ + msg => "bootstrap_authentication_overridden_in_the_built_in_database", + user_id => UserID, + group_id => UserGroup, + suggestion => + "If you have made changes in other way, remove the user_id from the bootstrap file." + }), + insert_user(UserInfoRecord) + end. insert_user(#user_info{} = UserInfoRecord) -> mnesia:write(?TAB, UserInfoRecord, write). @@ -537,3 +554,25 @@ find_password_hash(_, _, _) -> is_superuser(#{<<"is_superuser">> := <<"true">>}) -> true; is_superuser(#{<<"is_superuser">> := true}) -> true; is_superuser(_) -> false. + +boostrap_user_from_file(Config, State) -> + case maps:get(boostrap_file, Config, <<>>) of + <<>> -> + ok; + FileName0 -> + #{boostrap_type := Type} = Config, + FileName = emqx_schema:naive_env_interpolation(FileName0), + case file:read_file(FileName) of + {ok, FileData} -> + %% if there is a key conflict, override with the key which from the bootstrap file + _ = import_users({Type, FileName, FileData}, State), + ok; + {error, Reason} -> + ?SLOG(warning, #{ + msg => "boostrap_authn_built_in_database_failed", + boostrap_file => FileName, + boostrap_type => Type, + reason => emqx_utils:explain_posix(Reason) + }) + end + end. diff --git a/apps/emqx_auth_mnesia/src/emqx_authn_mnesia_schema.erl b/apps/emqx_auth_mnesia/src/emqx_authn_mnesia_schema.erl index 7ad6616d4..6544874dc 100644 --- a/apps/emqx_auth_mnesia/src/emqx_authn_mnesia_schema.erl +++ b/apps/emqx_auth_mnesia/src/emqx_authn_mnesia_schema.erl @@ -46,7 +46,7 @@ select_union_member(_Kind, _Value) -> fields(builtin_db) -> [ {password_hash_algorithm, fun emqx_authn_password_hashing:type_rw/1} - ] ++ common_fields(); + ] ++ common_fields() ++ bootstrap_fields(); fields(builtin_db_api) -> [ {password_hash_algorithm, fun emqx_authn_password_hashing:type_rw_api/1} @@ -69,3 +69,24 @@ common_fields() -> {backend, emqx_authn_schema:backend(?AUTHN_BACKEND)}, {user_id_type, fun user_id_type/1} ] ++ emqx_authn_schema:common_fields(). + +bootstrap_fields() -> + [ + {bootstrap_file, + ?HOCON( + binary(), + #{ + desc => ?DESC(bootstrap_file), + required => false, + default => <<>> + } + )}, + {bootstrap_type, + ?HOCON( + ?ENUM([hash, plain]), #{ + desc => ?DESC(bootstrap_type), + required => false, + default => <<"plain">> + } + )} + ]. diff --git a/apps/emqx_auth_mnesia/test/emqx_authn_mnesia_SUITE.erl b/apps/emqx_auth_mnesia/test/emqx_authn_mnesia_SUITE.erl index 54409a73f..666b4a628 100644 --- a/apps/emqx_auth_mnesia/test/emqx_authn_mnesia_SUITE.erl +++ b/apps/emqx_auth_mnesia/test/emqx_authn_mnesia_SUITE.erl @@ -54,7 +54,74 @@ t_create(_) -> {ok, _} = emqx_authn_mnesia:create(?AUTHN_ID, Config0), Config1 = Config0#{password_hash_algorithm => #{name => sha256}}, - {ok, _} = emqx_authn_mnesia:create(?AUTHN_ID, Config1). + {ok, _} = emqx_authn_mnesia:create(?AUTHN_ID, Config1), + ok. +t_bootstrap_file(_) -> + Config = config(), + %% hash to hash + HashConfig = Config#{password_hash_algorithm => #{name => sha256, salt_position => suffix}}, + ?assertMatch( + [ + {user_info, {_, <<"myuser1">>}, _, _, true}, + {user_info, {_, <<"myuser2">>}, _, _, false} + ], + test_bootstrap_file(HashConfig, hash, <<"user-credentials.json">>) + ), + ?assertMatch( + [ + {user_info, {_, <<"myuser3">>}, _, _, true}, + {user_info, {_, <<"myuser4">>}, _, _, false} + ], + test_bootstrap_file(HashConfig, hash, <<"user-credentials.csv">>) + ), + + %% plain to plain + PlainConfig = Config#{ + password_hash_algorithm => + #{name => plain, salt_position => disable} + }, + ?assertMatch( + [ + {user_info, {_, <<"myuser1">>}, <<"password1">>, _, true}, + {user_info, {_, <<"myuser2">>}, <<"password2">>, _, false} + ], + test_bootstrap_file(PlainConfig, plain, <<"user-credentials-plain.json">>) + ), + ?assertMatch( + [ + {user_info, {_, <<"myuser3">>}, <<"password3">>, _, true}, + {user_info, {_, <<"myuser4">>}, <<"password4">>, _, false} + ], + test_bootstrap_file(PlainConfig, plain, <<"user-credentials-plain.csv">>) + ), + %% plain to hash + ?assertMatch( + [ + {user_info, {_, <<"myuser1">>}, _, _, true}, + {user_info, {_, <<"myuser2">>}, _, _, false} + ], + test_bootstrap_file(HashConfig, plain, <<"user-credentials-plain.json">>) + ), + ?assertMatch( + [ + {user_info, {_, <<"myuser3">>}, _, _, true}, + {user_info, {_, <<"myuser4">>}, _, _, false} + ], + test_bootstrap_file(HashConfig, plain, <<"user-credentials-plain.csv">>) + ), + ok. + +test_bootstrap_file(Config0, Type, File) -> + {Type, Filename, _FileData} = sample_filename_and_data(Type, File), + Config2 = Config0#{ + boostrap_file => Filename, + boostrap_type => Type + }, + {ok, State0} = emqx_authn_mnesia:create(?AUTHN_ID, Config2), + Result = ets:tab2list(emqx_authn_mnesia), + ok = emqx_authn_mnesia:destroy(State0), + ?assertMatch([], ets:tab2list(emqx_authn_mnesia)), + Result. t_update(_) -> Config0 = config(), diff --git a/changes/ce/feat-13336.en.md b/changes/ce/feat-13336.en.md new file mode 100644 index 000000000..ff09f624b --- /dev/null +++ b/changes/ce/feat-13336.en.md @@ -0,0 +1 @@ +Added new configs `bootstrap_file` and `bootstrap_type` for built-in database for authentication to support bootstrapping the table with csv and json file. diff --git a/rel/i18n/emqx_authn_mnesia_schema.hocon b/rel/i18n/emqx_authn_mnesia_schema.hocon index b0d1a8517..3c9a24d2c 100644 --- a/rel/i18n/emqx_authn_mnesia_schema.hocon +++ b/rel/i18n/emqx_authn_mnesia_schema.hocon @@ -9,4 +9,34 @@ user_id_type.desc: user_id_type.label: """Authentication ID Type""" +bootstrap_file.desc: +"""The bootstrap file imports users into the built-in database. +The file content format is determined by `bootstrap_type`. +Remove the item from the bootstrap file when you have made changes in other way, +otherwise, after restarting, the bootstrap item will be overridden again.""" + +bootstrap_file.label: +"""Bootstrap File Path""" + +bootstrap_type.desc: +"""Specify which type of content the bootstrap file has. + +- **`plain`**: + - Expected data fields: `user_id`, `password`, `is_superuser` + - `user_id`: Can be Client ID or username, depending on built-in database authentication's `user_id_type` config. + - `password`: User's plaintext password. + - `is_superuser`: Boolean, user's administrative status. + +- **`hash`**: + - Expected data fields: `user_id`,`password_hash`,`salt`,`is_superuser` + - Definitions similar to `plain` type, with `password_hash` and `salt` added for security. + +The content can be either in CSV, or JSON format. + +Here is a CSV example: `user_id,password_hash,salt,is_superuser\nmy_user,b6c743545a7817ae8c8f624371d5f5f0373234bb0ff36b8ffbf19bce0e06ab75,de1024f462fb83910fd13151bd4bd235,true` + +And JSON content should be decoded into an array of objects, for example: `[{"user_id": "my_user","password": "s3cr3tp@ssw0rd","is_superuser": true}]`. + +The hash string for `password_hash` depends on how `password_hash_algorithm` is configured for the built-in database authentication mechanism. For example, if it's configured as `password_hash_algorithm {name = sha256, salt_position = suffix}`, then the salt is appended to the password before hashed. Here is the equivalent Python expression: `hashlib.sha256(password + salt).hexdigest()`.""" + }