add ability to reload authentication module

This commit is contained in:
Evgenii Alekseev 2021-09-17 16:05:38 +03:00
parent 41731ca359
commit 16bb1403a1
15 changed files with 219 additions and 12 deletions

View File

@ -354,6 +354,7 @@ def _set_user_parser(root: SubParserAction) -> argparse.ArgumentParser:
type=UserAccess, type=UserAccess,
choices=UserAccess, choices=UserAccess,
default=UserAccess.Read) default=UserAccess.Read)
parser.add_argument("--no-reload", help="do not reload authentication module", action="store_true")
parser.add_argument("-p", "--password", help="user password") parser.add_argument("-p", "--password", help="user password")
parser.add_argument("-r", "--remove", help="remove user from configuration", action="store_true") 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) parser.set_defaults(handler=handlers.User, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True)

View File

@ -23,6 +23,7 @@ import getpass
from pathlib import Path from pathlib import Path
from typing import Type from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.user import User as MUser from ahriman.models.user import User as MUser
@ -53,6 +54,10 @@ class User(Handler):
User.create_configuration(auth_configuration, user, salt, args.as_service) User.create_configuration(auth_configuration, user, salt, args.as_service)
User.write_configuration(configuration) User.write_configuration(configuration)
if not args.no_reload:
client = Application(architecture, configuration, no_report=False).repository.reporter
client.reload_auth()
@staticmethod @staticmethod
def clear_user(configuration: Configuration, user: MUser) -> None: def clear_user(configuration: Configuration, user: MUser) -> None:
""" """

View File

@ -26,10 +26,13 @@ from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Optional, Type
from ahriman.core.exceptions import InitializeException
class Configuration(configparser.RawConfigParser): class Configuration(configparser.RawConfigParser):
""" """
extension for built-in configuration parser extension for built-in configuration parser
:ivar architecture: repository architecture
:ivar path: path to root configuration file :ivar path: path to root configuration file
:cvar ARCHITECTURE_SPECIFIC_SECTIONS: known sections which can be architecture specific (required by dump) :cvar ARCHITECTURE_SPECIFIC_SECTIONS: known sections which can be architecture specific (required by dump)
:cvar DEFAULT_LOG_FORMAT: default log format (in case of fallback) :cvar DEFAULT_LOG_FORMAT: default log format (in case of fallback)
@ -49,6 +52,7 @@ class Configuration(configparser.RawConfigParser):
"list": lambda value: value.split(), "list": lambda value: value.split(),
"path": self.__convert_path, "path": self.__convert_path,
}) })
self.architecture: Optional[str] = None
self.path: Optional[Path] = None self.path: Optional[Path] = None
@property @property
@ -90,16 +94,16 @@ class Configuration(configparser.RawConfigParser):
""" """
return f"{section}:{suffix}" return f"{section}:{suffix}"
def __convert_path(self, parsed: str) -> Path: def __convert_path(self, value: str) -> Path:
""" """
convert string value to path object convert string value to path object
:param parsed: string configuration value :param value: string configuration value
:return: path object which represents the configuration value :return: path object which represents the configuration value
""" """
value = Path(parsed) path = Path(value)
if self.path is None or value.is_absolute(): if self.path is None or path.is_absolute():
return value return path
return self.path.parent / value return self.path.parent / path
def dump(self) -> Dict[str, Dict[str, str]]: def dump(self) -> Dict[str, Dict[str, str]]:
""" """
@ -165,6 +169,7 @@ class Configuration(configparser.RawConfigParser):
merge architecture specific sections into main configuration merge architecture specific sections into main configuration
:param architecture: repository architecture :param architecture: repository architecture
""" """
self.architecture = architecture
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS: for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
# get overrides # get overrides
specific = self.section_name(section, architecture) specific = self.section_name(section, architecture)
@ -180,6 +185,15 @@ class Configuration(configparser.RawConfigParser):
continue continue
self.remove_section(foreign) self.remove_section(foreign)
def reload(self) -> None:
"""
reload configuration if possible or raise exception otherwise
"""
if self.path is None or self.architecture is None:
raise InitializeException("Configuration path and/or architecture are not set")
self.load(self.path)
self.merge_sections(self.architecture)
def set_option(self, section: str, option: str, value: Optional[str]) -> None: def set_option(self, section: str, option: str, value: Optional[str]) -> None:
""" """
set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist

View File

@ -77,6 +77,11 @@ class Client:
""" """
return BuildStatus() return BuildStatus()
def reload_auth(self) -> None:
"""
reload authentication module call
"""
def remove(self, base: str) -> None: def remove(self, base: str) -> None:
""" """
remove packages from watcher remove packages from watcher

View File

@ -67,6 +67,13 @@ class WebClient(Client):
""" """
return f"{self.address}/user-api/v1/login" return f"{self.address}/user-api/v1/login"
@property
def _reload_auth_url(self) -> str:
"""
:return: full url for web service to reload authentication module
"""
return f"{self.address}/status-api/v1/reload-auth"
@property @property
def _status_url(self) -> str: def _status_url(self) -> str:
""" """
@ -191,6 +198,18 @@ class WebClient(Client):
self.logger.exception("could not get service status") self.logger.exception("could not get service status")
return BuildStatus() return BuildStatus()
def reload_auth(self) -> None:
"""
reload authentication module call
"""
try:
response = self.__session.post(self._reload_auth_url)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception("could not reload auth module: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not reload auth module")
def remove(self, base: str) -> None: def remove(self, base: str) -> None:
""" """
remove packages from watcher remove packages from watcher

View File

@ -22,6 +22,7 @@ from pathlib import Path
from ahriman.web.views.index import IndexView from ahriman.web.views.index import IndexView
from ahriman.web.views.service.add import AddView from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.reload_auth import ReloadAuthView
from ahriman.web.views.service.remove import RemoveView from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.search import SearchView from ahriman.web.views.service.search import SearchView
from ahriman.web.views.status.ahriman import AhrimanView from ahriman.web.views.status.ahriman import AhrimanView
@ -43,12 +44,14 @@ def setup_routes(application: Application, static_path: Path) -> None:
POST /service-api/v1/add add new packages to repository POST /service-api/v1/add add new packages to repository
POST /service-api/v1/reload-auth reload authentication module
POST /service-api/v1/remove remove existing package from repository POST /service-api/v1/remove remove existing package from repository
POST /service-api/v1/update update packages in repository, actually it is just alias for add
GET /service-api/v1/search search for substring in AUR GET /service-api/v1/search search for substring in AUR
POST /service-api/v1/update update packages in repository, actually it is just alias for add
GET /status-api/v1/ahriman get current service status GET /status-api/v1/ahriman get current service status
POST /status-api/v1/ahriman update service status POST /status-api/v1/ahriman update service status
@ -75,6 +78,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/service-api/v1/add", AddView) application.router.add_post("/service-api/v1/add", AddView)
application.router.add_post("/service-api/v1/reload-auth", ReloadAuthView)
application.router.add_post("/service-api/v1/remove", RemoveView) application.router.add_post("/service-api/v1/remove", RemoveView)
application.router.add_get("/service-api/v1/search", SearchView, allow_head=False) application.router.add_get("/service-api/v1/search", SearchView, allow_head=False)

View File

@ -21,6 +21,7 @@ from aiohttp.web import View
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ahriman.core.auth.auth import Auth from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
@ -30,6 +31,14 @@ class BaseView(View):
base web view to make things typed base web view to make things typed
""" """
@property
def configuration(self) -> Configuration:
"""
:return: configuration instance
"""
configuration: Configuration = self.request.app["configuration"]
return configuration
@property @property
def service(self) -> Watcher: def service(self) -> Watcher:
""" """

View File

@ -0,0 +1,40 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import Response
from aiohttp.web_exceptions import HTTPNoContent
from ahriman.core.auth.auth import Auth
from ahriman.web.views.base import BaseView
class ReloadAuthView(BaseView):
"""
reload authentication module web view
"""
async def post(self) -> Response:
"""
reload authentication module. No parameters supported here
:return: 204 on success
"""
self.configuration.reload()
self.request.app["validator"] = Auth.load(self.configuration)
return HTTPNoContent()

View File

@ -21,6 +21,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.password = "pa55w0rd" args.password = "pa55w0rd"
args.access = UserAccess.Read args.access = UserAccess.Read
args.as_service = False args.as_service = False
args.no_reload = False
args.remove = False args.remove = False
return args return args
@ -30,11 +31,13 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
must run command must run command
""" """
args = _default_args(args) args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration") get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration")
create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration") create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration")
write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration") write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration")
create_user = mocker.patch("ahriman.application.handlers.User.create_user") create_user = mocker.patch("ahriman.application.handlers.User.create_user")
get_salt_mock = mocker.patch("ahriman.application.handlers.User.get_salt") get_salt_mock = mocker.patch("ahriman.application.handlers.User.get_salt")
reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth")
User.run(args, "x86_64", configuration, True) User.run(args, "x86_64", configuration, True)
get_auth_configuration_mock.assert_called_once() get_auth_configuration_mock.assert_called_once()
@ -42,6 +45,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
create_user.assert_called_once() create_user.assert_called_once()
get_salt_mock.assert_called_once() get_salt_mock.assert_called_once()
write_configuration_mock.assert_called_once() write_configuration_mock.assert_called_once()
reload_mock.assert_called_once()
def test_run_remove(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run_remove(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
@ -50,14 +54,32 @@ def test_run_remove(args: argparse.Namespace, configuration: Configuration, mock
""" """
args = _default_args(args) args = _default_args(args)
args.remove = True args.remove = True
mocker.patch("pathlib.Path.mkdir")
get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration") get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration")
create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration") create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration")
write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration") write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration")
reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth")
User.run(args, "x86_64", configuration, True) User.run(args, "x86_64", configuration, True)
get_auth_configuration_mock.assert_called_once() get_auth_configuration_mock.assert_called_once()
create_configuration_mock.assert_not_called() create_configuration_mock.assert_not_called()
write_configuration_mock.assert_called_once() write_configuration_mock.assert_called_once()
reload_mock.assert_called_once()
def test_run_no_reload(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command with no reload
"""
args = _default_args(args)
args.no_reload = True
mocker.patch("ahriman.application.handlers.User.get_auth_configuration")
mocker.patch("ahriman.application.handlers.User.create_configuration")
mocker.patch("ahriman.application.handlers.User.write_configuration")
reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth")
User.run(args, "x86_64", configuration, True)
reload_mock.assert_not_called()
def test_clear_user(configuration: Configuration, user: MUser) -> None: def test_clear_user(configuration: Configuration, user: MUser) -> None:

View File

@ -61,6 +61,13 @@ def test_get_self(client: Client) -> None:
assert client.get_self().status == BuildStatusEnum.Unknown assert client.get_self().status == BuildStatusEnum.Unknown
def test_reload_auth(client: Client) -> None:
"""
must process auth reload without errors
"""
client.reload_auth()
def test_remove(client: Client, package_ahriman: Package) -> None: def test_remove(client: Client, package_ahriman: Package) -> None:
""" """
must process remove without errors must process remove without errors

View File

@ -230,6 +230,32 @@ def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture
assert web_client.get_self().status == BuildStatusEnum.Unknown assert web_client.get_self().status == BuildStatusEnum.Unknown
def test_reload_auth(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must process auth reload
"""
requests_mock = mocker.patch("requests.Session.post")
web_client.reload_auth()
requests_mock.assert_called_with(pytest.helpers.anyvar(str, True))
def test_reload_auth_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during auth reload
"""
mocker.patch("requests.Session.post", side_effect=Exception())
web_client.reload_auth()
def test_reload_auth_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during removal
"""
mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError())
web_client.reload_auth()
def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must process package removal must process package removal

View File

@ -5,6 +5,7 @@ import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InitializeException
def test_from_path(mocker: MockerFixture) -> None: def test_from_path(mocker: MockerFixture) -> None:
@ -115,6 +116,7 @@ def test_getlist_single(configuration: Configuration) -> None:
""" """
configuration.set_option("build", "test_list", "a") configuration.set_option("build", "test_list", "a")
assert configuration.getlist("build", "test_list") == ["a"] assert configuration.getlist("build", "test_list") == ["a"]
assert configuration.getlist("build", "test_list") == ["a"]
def test_load_includes_missing(configuration: Configuration) -> None: def test_load_includes_missing(configuration: Configuration) -> None:
@ -170,6 +172,36 @@ def test_merge_sections_missing(configuration: Configuration) -> None:
assert configuration.get("build", "key") == "value" assert configuration.get("build", "key") == "value"
def test_reload(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must reload configuration
"""
load_mock = mocker.patch("ahriman.core.configuration.Configuration.load")
merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections")
configuration.reload()
load_mock.assert_called_once()
merge_mock.assert_called_once()
def test_reload_no_architecture(configuration: Configuration) -> None:
"""
must raise exception on reload if no architecture set
"""
configuration.architecture = None
with pytest.raises(InitializeException):
configuration.reload()
def test_reload_no_path(configuration: Configuration) -> None:
"""
must raise exception on reload if no path set
"""
configuration.path = None
with pytest.raises(InitializeException):
configuration.reload()
def test_set_option(configuration: Configuration) -> None: def test_set_option(configuration: Configuration) -> None:
""" """
must set option correctly must set option correctly

View File

@ -0,0 +1,15 @@
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
reload_mock = mocker.patch("ahriman.core.configuration.Configuration.reload")
load_mock = mocker.patch("ahriman.core.auth.auth.Auth.load")
response = await client.post("/service-api/v1/reload-auth")
assert response.ok
reload_mock.assert_called_once()
load_mock.assert_called_with(client.app["configuration"])

View File

@ -6,19 +6,19 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
""" """
must call post request correctly must call post request correctly
""" """
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") remove_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]}) response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]})
assert response.ok assert response.ok
add_mock.assert_called_with(["ahriman"]) remove_mock.assert_called_with(["ahriman"])
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None: async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
""" """
must raise exception on missing packages payload must raise exception on missing packages payload
""" """
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") remove_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove")
response = await client.post("/service-api/v1/remove") response = await client.post("/service-api/v1/remove")
assert response.status == 400 assert response.status == 400
add_mock.assert_not_called() remove_mock.assert_not_called()

View File

@ -5,6 +5,13 @@ from multidict import MultiDict
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
def test_configuration(base: BaseView) -> None:
"""
must return configuration
"""
assert base.configuration
def test_service(base: BaseView) -> None: def test_service(base: BaseView) -> None:
""" """
must return service must return service