From 41731ca3599342a47ed6dc7ff6e79e6da4eefda0 Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Thu, 16 Sep 2021 02:36:53 +0300 Subject: [PATCH] add ability to remove an user also replace old user by new one before creation --- src/ahriman/application/ahriman.py | 53 +++-- src/ahriman/application/handlers/__init__.py | 2 +- .../handlers/{create_user.py => user.py} | 60 +++-- .../handlers/test_handler_create_user.py | 164 ------------- .../application/handlers/test_handler_user.py | 224 ++++++++++++++++++ tests/ahriman/application/test_ahriman.py | 44 ++-- 6 files changed, 316 insertions(+), 231 deletions(-) rename src/ahriman/application/handlers/{create_user.py => user.py} (68%) delete mode 100644 tests/ahriman/application/handlers/test_handler_create_user.py create mode 100644 tests/ahriman/application/handlers/test_handler_user.py diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index c6c2d485..7b3bd232 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -62,7 +62,6 @@ def _parser() -> argparse.ArgumentParser: _set_check_parser(subparsers) _set_clean_parser(subparsers) _set_config_parser(subparsers) - _set_create_user_parser(subparsers) _set_init_parser(subparsers) _set_key_import_parser(subparsers) _set_rebuild_parser(subparsers) @@ -76,6 +75,7 @@ def _parser() -> argparse.ArgumentParser: _set_status_update_parser(subparsers) _set_sync_parser(subparsers) _set_update_parser(subparsers) + _set_user_parser(subparsers) _set_web_parser(subparsers) return parser @@ -141,31 +141,6 @@ def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser: return parser -def _set_create_user_parser(root: SubParserAction) -> argparse.ArgumentParser: - """ - add parser for create user subcommand - :param root: subparsers for the commands - :return: created argument parser - """ - parser = root.add_parser( - "create-user", - help="create user for web services", - description="create user for web services with password and role. In case if password was not entered it will be asked interactively", - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument("username", help="username for web service") - parser.add_argument("--as-service", help="add user as service user", action="store_true") - parser.add_argument("-r", "--role", help="user role", type=UserAccess, choices=UserAccess, default=UserAccess.Read) - parser.add_argument("-p", "--password", help="user password") - parser.set_defaults( - handler=handlers.CreateUser, - architecture=[""], - lock=None, - no_log=True, - no_report=True, - unsafe=True) - return parser - - def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser: """ add parser for init subcommand @@ -359,6 +334,32 @@ def _set_update_parser(root: SubParserAction) -> argparse.ArgumentParser: return parser +def _set_user_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for create user subcommand + :param root: subparsers for the commands + :return: created argument parser + """ + parser = root.add_parser( + "user", + help="manage users for web services", + description="manage users for web services with password and role. In case if password was not entered it will be asked interactively", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("username", help="username for web service") + parser.add_argument("--as-service", help="add user as service user", action="store_true") + parser.add_argument( + "-a", + "--access", + help="user access level", + type=UserAccess, + choices=UserAccess, + default=UserAccess.Read) + parser.add_argument("-p", "--password", help="user password") + parser.add_argument("-r", "--remove", help="remove user from configuration", action="store_true") + parser.set_defaults(handler=handlers.User, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True) + return parser + + def _set_web_parser(root: SubParserAction) -> argparse.ArgumentParser: """ add parser for web subcommand diff --git a/src/ahriman/application/handlers/__init__.py b/src/ahriman/application/handlers/__init__.py index 58574521..1c94dc7d 100644 --- a/src/ahriman/application/handlers/__init__.py +++ b/src/ahriman/application/handlers/__init__.py @@ -21,7 +21,6 @@ from ahriman.application.handlers.handler import Handler from ahriman.application.handlers.add import Add from ahriman.application.handlers.clean import Clean -from ahriman.application.handlers.create_user import CreateUser from ahriman.application.handlers.dump import Dump from ahriman.application.handlers.init import Init from ahriman.application.handlers.key_import import KeyImport @@ -36,4 +35,5 @@ from ahriman.application.handlers.status import Status from ahriman.application.handlers.status_update import StatusUpdate from ahriman.application.handlers.sync import Sync from ahriman.application.handlers.update import Update +from ahriman.application.handlers.user import User from ahriman.application.handlers.web import Web diff --git a/src/ahriman/application/handlers/create_user.py b/src/ahriman/application/handlers/user.py similarity index 68% rename from src/ahriman/application/handlers/create_user.py rename to src/ahriman/application/handlers/user.py index 4be5c92a..25b19ee0 100644 --- a/src/ahriman/application/handlers/create_user.py +++ b/src/ahriman/application/handlers/user.py @@ -25,12 +25,13 @@ from typing import Type from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration -from ahriman.models.user import User +from ahriman.models.user import User as MUser +from ahriman.models.user_access import UserAccess -class CreateUser(Handler): +class User(Handler): """ - create user handler + user management handler """ @classmethod @@ -43,13 +44,30 @@ class CreateUser(Handler): :param configuration: configuration instance :param no_report: force disable reporting """ - salt = CreateUser.get_salt(configuration) - user = CreateUser.create_user(args) - auth_configuration = CreateUser.get_auth_configuration(configuration.include) - CreateUser.create_configuration(auth_configuration, user, salt, args.as_service) + salt = User.get_salt(configuration) + user = User.create_user(args) + auth_configuration = User.get_auth_configuration(configuration.include) + + User.clear_user(auth_configuration, user) + if not args.remove: + User.create_configuration(auth_configuration, user, salt, args.as_service) + User.write_configuration(configuration) @staticmethod - def create_configuration(configuration: Configuration, user: User, salt: str, as_service_user: bool) -> None: + def clear_user(configuration: Configuration, user: MUser) -> None: + """ + remove user user from configuration file in case if it exists + :param configuration: configuration instance + :param user: user descriptor + """ + for role in UserAccess: + section = Configuration.section_name("auth", role.value) + if not configuration.has_option(section, user.username): + continue + configuration.remove_option(section, user.username) + + @staticmethod + def create_configuration(configuration: Configuration, user: MUser, salt: str, as_service_user: bool) -> None: """ put new user to configuration :param configuration: configuration instance @@ -65,19 +83,14 @@ class CreateUser(Handler): configuration.set_option("web", "username", user.username) configuration.set_option("web", "password", user.password) - if configuration.path is None: - return - with configuration.path.open("w") as ahriman_configuration: - configuration.write(ahriman_configuration) - @staticmethod - def create_user(args: argparse.Namespace) -> User: + def create_user(args: argparse.Namespace) -> MUser: """ create user descriptor from arguments :param args: command line args :return: built user descriptor """ - user = User(args.username, args.password, args.role) + user = MUser(args.username, args.password, args.access) if user.password is None: user.password = getpass.getpass() return user @@ -91,8 +104,7 @@ class CreateUser(Handler): """ target = include_path / "auth.ini" configuration = Configuration() - if target.is_file(): # load current configuration in case if it exists - configuration.load(target) + configuration.load(target) return configuration @@ -107,4 +119,16 @@ class CreateUser(Handler): salt = configuration.get("auth", "salt", fallback=None) if salt: return salt - return User.generate_password(salt_length) + return MUser.generate_password(salt_length) + + @staticmethod + def write_configuration(configuration: Configuration) -> None: + """ + write configuration file + :param configuration: configuration instance + """ + if configuration.path is None: + return # should never happen actually + with configuration.path.open("w") as ahriman_configuration: + configuration.write(ahriman_configuration) + configuration.path.chmod(0o600) diff --git a/tests/ahriman/application/handlers/test_handler_create_user.py b/tests/ahriman/application/handlers/test_handler_create_user.py deleted file mode 100644 index cda99fdb..00000000 --- a/tests/ahriman/application/handlers/test_handler_create_user.py +++ /dev/null @@ -1,164 +0,0 @@ -import argparse -import pytest - -from pathlib import Path -from pytest_mock import MockerFixture -from unittest import mock - -from ahriman.application.handlers import CreateUser -from ahriman.core.configuration import Configuration -from ahriman.models.user import User -from ahriman.models.user_access import UserAccess - - -def _default_args(args: argparse.Namespace) -> argparse.Namespace: - """ - default arguments for these test cases - :param args: command line arguments fixture - :return: generated arguments for these test cases - """ - args.username = "user" - args.password = "pa55w0rd" - args.role = UserAccess.Read - args.as_service = False - return args - - -def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: - """ - must run command - """ - args = _default_args(args) - get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_auth_configuration") - create_configuration_mock = mocker.patch("ahriman.application.handlers.CreateUser.create_configuration") - create_user = mocker.patch("ahriman.application.handlers.CreateUser.create_user") - get_salt_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_salt") - - CreateUser.run(args, "x86_64", configuration, True) - get_auth_configuration_mock.assert_called_once() - create_configuration_mock.assert_called_once() - create_user.assert_called_once() - get_salt_mock.assert_called_once() - - -def test_create_configuration(configuration: Configuration, user: User, mocker: MockerFixture) -> None: - """ - must correctly create configuration file - """ - section = Configuration.section_name("auth", user.access.value) - mocker.patch("pathlib.Path.open") - set_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option") - write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") - - CreateUser.create_configuration(configuration, user, "salt", False) - write_mock.assert_called_once() - set_mock.assert_has_calls([ - mock.call("auth", "salt", pytest.helpers.anyvar(str)), - mock.call(section, user.username, pytest.helpers.anyvar(str)) - ]) - - -def test_create_configuration_not_loaded(configuration: Configuration, user: User, mocker: MockerFixture) -> None: - """ - must do nothing in case if configuration is not loaded - """ - configuration.path = None - mocker.patch("pathlib.Path.open") - write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") - - CreateUser.create_configuration(configuration, user, "salt", False) - write_mock.assert_not_called() - - -def test_create_configuration_user_exists(configuration: Configuration, user: User, mocker: MockerFixture) -> None: - """ - must correctly update configuration file if user already exists - """ - section = Configuration.section_name("auth", user.access.value) - configuration.set_option(section, user.username, "") - mocker.patch("pathlib.Path.open") - mocker.patch("ahriman.core.configuration.Configuration.write") - - CreateUser.create_configuration(configuration, user, "salt", False) - generated = User.from_option(user.username, configuration.get(section, user.username)) - assert generated.check_credentials(user.password, configuration.get("auth", "salt")) - - -def test_create_configuration_with_plain_password( - configuration: Configuration, - user: User, - mocker: MockerFixture) -> None: - """ - must set plain text password and user for the service - """ - section = Configuration.section_name("auth", user.access.value) - mocker.patch("pathlib.Path.open") - mocker.patch("ahriman.core.configuration.Configuration.write") - - CreateUser.create_configuration(configuration, user, "salt", True) - - generated = User.from_option(user.username, configuration.get(section, user.username)) - service = User.from_option(configuration.get("web", "username"), configuration.get("web", "password")) - assert generated.username == service.username - assert generated.check_credentials(service.password, configuration.get("auth", "salt")) - - -def test_create_user(args: argparse.Namespace, user: User) -> None: - """ - must create user - """ - args = _default_args(args) - generated = CreateUser.create_user(args) - assert generated.username == user.username - assert generated.access == user.access - - -def test_create_user_getpass(args: argparse.Namespace, mocker: MockerFixture) -> None: - """ - must create user and get password from command line - """ - args = _default_args(args) - args.password = None - - getpass_mock = mocker.patch("getpass.getpass", return_value="password") - generated = CreateUser.create_user(args) - - getpass_mock.assert_called_once() - assert generated.password == "password" - - -def test_get_salt_read(configuration: Configuration) -> None: - """ - must read salt from configuration - """ - assert CreateUser.get_salt(configuration) == "salt" - - -def test_get_salt_generate(configuration: Configuration) -> None: - """ - must generate salt if it does not exist - """ - configuration.remove_option("auth", "salt") - - salt = CreateUser.get_salt(configuration, 16) - assert salt - assert len(salt) == 16 - - -def test_get_auth_configuration() -> None: - """ - must load empty configuration - """ - assert CreateUser.get_auth_configuration(Path("path")) - - -def test_get_auth_configuration_exists(mocker: MockerFixture) -> None: - """ - must load configuration from filesystem - """ - mocker.patch("pathlib.Path.open") - mocker.patch("pathlib.Path.is_file", return_value=True) - read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") - - CreateUser.get_auth_configuration(Path("path")) - read_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_user.py b/tests/ahriman/application/handlers/test_handler_user.py new file mode 100644 index 00000000..cc4576e2 --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_user.py @@ -0,0 +1,224 @@ +import argparse +import pytest + +from pathlib import Path +from pytest_mock import MockerFixture +from unittest import mock + +from ahriman.application.handlers import User +from ahriman.core.configuration import Configuration +from ahriman.models.user import User as MUser +from ahriman.models.user_access import UserAccess + + +def _default_args(args: argparse.Namespace) -> argparse.Namespace: + """ + default arguments for these test cases + :param args: command line arguments fixture + :return: generated arguments for these test cases + """ + args.username = "user" + args.password = "pa55w0rd" + args.access = UserAccess.Read + args.as_service = False + args.remove = False + return args + + +def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must run command + """ + args = _default_args(args) + get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration") + create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration") + write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration") + create_user = mocker.patch("ahriman.application.handlers.User.create_user") + get_salt_mock = mocker.patch("ahriman.application.handlers.User.get_salt") + + User.run(args, "x86_64", configuration, True) + get_auth_configuration_mock.assert_called_once() + create_configuration_mock.assert_called_once() + create_user.assert_called_once() + get_salt_mock.assert_called_once() + write_configuration_mock.assert_called_once() + + +def test_run_remove(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must remove user if remove flag supplied + """ + args = _default_args(args) + args.remove = True + get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration") + create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration") + write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration") + + User.run(args, "x86_64", configuration, True) + get_auth_configuration_mock.assert_called_once() + create_configuration_mock.assert_not_called() + write_configuration_mock.assert_called_once() + + +def test_clear_user(configuration: Configuration, user: MUser) -> None: + """ + must clear user from configuration + """ + section = Configuration.section_name("auth", user.access.value) + configuration.set_option(section, user.username, user.password) + + User.clear_user(configuration, user) + assert configuration.get(section, user.username, fallback=None) is None + + +def test_clear_user_multiple_sections(configuration: Configuration, user: MUser) -> None: + """ + must clear user from configuration from all sections + """ + for role in UserAccess: + section = Configuration.section_name("auth", role.value) + configuration.set_option(section, user.username, user.password) + + User.clear_user(configuration, user) + for role in UserAccess: + section = Configuration.section_name("auth", role.value) + assert configuration.get(section, user.username, fallback=None) is None + + +def test_create_configuration(configuration: Configuration, user: MUser, mocker: MockerFixture) -> None: + """ + must correctly create configuration file + """ + section = Configuration.section_name("auth", user.access.value) + mocker.patch("pathlib.Path.open") + set_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option") + + User.create_configuration(configuration, user, "salt", False) + set_mock.assert_has_calls([ + mock.call("auth", "salt", pytest.helpers.anyvar(str)), + mock.call(section, user.username, pytest.helpers.anyvar(str)) + ]) + + +def test_create_configuration_user_exists(configuration: Configuration, user: MUser, mocker: MockerFixture) -> None: + """ + must correctly update configuration file if user already exists + """ + section = Configuration.section_name("auth", user.access.value) + configuration.set_option(section, user.username, "") + mocker.patch("pathlib.Path.open") + + User.create_configuration(configuration, user, "salt", False) + generated = MUser.from_option(user.username, configuration.get(section, user.username)) + assert generated.check_credentials(user.password, configuration.get("auth", "salt")) + + +def test_create_configuration_with_plain_password( + configuration: Configuration, + user: MUser, + mocker: MockerFixture) -> None: + """ + must set plain text password and user for the service + """ + section = Configuration.section_name("auth", user.access.value) + mocker.patch("pathlib.Path.open") + + User.create_configuration(configuration, user, "salt", True) + + generated = MUser.from_option(user.username, configuration.get(section, user.username)) + service = MUser.from_option(configuration.get("web", "username"), configuration.get("web", "password")) + assert generated.username == service.username + assert generated.check_credentials(service.password, configuration.get("auth", "salt")) + + +def test_create_user(args: argparse.Namespace, user: MUser) -> None: + """ + must create user + """ + args = _default_args(args) + generated = User.create_user(args) + assert generated.username == user.username + assert generated.access == user.access + + +def test_create_user_getpass(args: argparse.Namespace, mocker: MockerFixture) -> None: + """ + must create user and get password from command line + """ + args = _default_args(args) + args.password = None + + getpass_mock = mocker.patch("getpass.getpass", return_value="password") + generated = User.create_user(args) + + getpass_mock.assert_called_once() + assert generated.password == "password" + + +def test_get_salt_read(configuration: Configuration) -> None: + """ + must read salt from configuration + """ + assert User.get_salt(configuration) == "salt" + + +def test_get_salt_generate(configuration: Configuration) -> None: + """ + must generate salt if it does not exist + """ + configuration.remove_option("auth", "salt") + + salt = User.get_salt(configuration, 16) + assert salt + assert len(salt) == 16 + + +def test_get_auth_configuration_exists(mocker: MockerFixture) -> None: + """ + must load configuration from filesystem + """ + mocker.patch("pathlib.Path.open") + mocker.patch("pathlib.Path.is_file", return_value=True) + read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") + + assert User.get_auth_configuration(Path("path")) + read_mock.assert_called_once() + + +def test_get_auth_configuration_not_exists(mocker: MockerFixture) -> None: + """ + must try to load configuration from filesystem + """ + mocker.patch("pathlib.Path.open") + mocker.patch("pathlib.Path.is_file", return_value=False) + read_mock = mocker.patch("ahriman.core.configuration.Configuration.read") + + assert User.get_auth_configuration(Path("path")) + read_mock.assert_called_once() + + +def test_write_configuration(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must write configuration + """ + mocker.patch("pathlib.Path.open") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") + chmod_mock = mocker.patch("pathlib.Path.chmod") + + User.write_configuration(configuration) + write_mock.assert_called_once() + chmod_mock.assert_called_once() + + +def test_write_configuration_not_loaded(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must do nothing in case if configuration is not loaded + """ + configuration.path = None + mocker.patch("pathlib.Path.open") + write_mock = mocker.patch("ahriman.core.configuration.Configuration.write") + chmod_mock = mocker.patch("pathlib.Path.chmod") + + User.write_configuration(configuration) + write_mock.assert_not_called() + chmod_mock.assert_not_called() diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index a4df4bc9..f373f0ff 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -84,28 +84,6 @@ def test_subparsers_config(parser: argparse.ArgumentParser) -> None: assert args.unsafe -def test_subparsers_create_user(parser: argparse.ArgumentParser) -> None: - """ - create-user command must imply architecture, lock, no-log, no-report and unsafe - """ - args = parser.parse_args(["create-user", "username"]) - assert args.architecture == [""] - assert args.lock is None - assert args.no_log - assert args.no_report - assert args.unsafe - - -def test_subparsers_create_user_option_role(parser: argparse.ArgumentParser) -> None: - """ - create-user command must convert role option to useraccess instance - """ - args = parser.parse_args(["create-user", "username"]) - assert isinstance(args.role, UserAccess) - args = parser.parse_args(["create-user", "username", "--role", "write"]) - assert isinstance(args.role, UserAccess) - - def test_subparsers_init(parser: argparse.ArgumentParser) -> None: """ init command must imply no_report @@ -258,6 +236,28 @@ def test_subparsers_update(parser: argparse.ArgumentParser) -> None: assert args.architecture == [] +def test_subparsers_user(parser: argparse.ArgumentParser) -> None: + """ + user command must imply architecture, lock, no-log, no-report and unsafe + """ + args = parser.parse_args(["user", "username"]) + assert args.architecture == [""] + assert args.lock is None + assert args.no_log + assert args.no_report + assert args.unsafe + + +def test_subparsers_user_option_role(parser: argparse.ArgumentParser) -> None: + """ + user command must convert role option to useraccess instance + """ + args = parser.parse_args(["user", "username"]) + assert isinstance(args.access, UserAccess) + args = parser.parse_args(["user", "username", "--access", "write"]) + assert isinstance(args.access, UserAccess) + + def test_subparsers_web(parser: argparse.ArgumentParser) -> None: """ web command must imply lock, no_report and parser