mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-04-24 15:27:17 +00:00
add ability to reload authentication module
This commit is contained in:
parent
41731ca359
commit
16bb1403a1
@ -354,6 +354,7 @@ def _set_user_parser(root: SubParserAction) -> argparse.ArgumentParser:
|
||||
type=UserAccess,
|
||||
choices=UserAccess,
|
||||
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("-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)
|
||||
|
@ -23,6 +23,7 @@ import getpass
|
||||
from pathlib import Path
|
||||
from typing import Type
|
||||
|
||||
from ahriman.application.application import Application
|
||||
from ahriman.application.handlers.handler import Handler
|
||||
from ahriman.core.configuration import Configuration
|
||||
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.write_configuration(configuration)
|
||||
|
||||
if not args.no_reload:
|
||||
client = Application(architecture, configuration, no_report=False).repository.reporter
|
||||
client.reload_auth()
|
||||
|
||||
@staticmethod
|
||||
def clear_user(configuration: Configuration, user: MUser) -> None:
|
||||
"""
|
||||
|
@ -26,10 +26,13 @@ from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from ahriman.core.exceptions import InitializeException
|
||||
|
||||
|
||||
class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
extension for built-in configuration parser
|
||||
:ivar architecture: repository architecture
|
||||
:ivar path: path to root configuration file
|
||||
: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)
|
||||
@ -49,6 +52,7 @@ class Configuration(configparser.RawConfigParser):
|
||||
"list": lambda value: value.split(),
|
||||
"path": self.__convert_path,
|
||||
})
|
||||
self.architecture: Optional[str] = None
|
||||
self.path: Optional[Path] = None
|
||||
|
||||
@property
|
||||
@ -90,16 +94,16 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
return f"{section}:{suffix}"
|
||||
|
||||
def __convert_path(self, parsed: str) -> Path:
|
||||
def __convert_path(self, value: str) -> Path:
|
||||
"""
|
||||
convert string value to path object
|
||||
:param parsed: string configuration value
|
||||
:param value: string configuration value
|
||||
:return: path object which represents the configuration value
|
||||
"""
|
||||
value = Path(parsed)
|
||||
if self.path is None or value.is_absolute():
|
||||
return value
|
||||
return self.path.parent / value
|
||||
path = Path(value)
|
||||
if self.path is None or path.is_absolute():
|
||||
return path
|
||||
return self.path.parent / path
|
||||
|
||||
def dump(self) -> Dict[str, Dict[str, str]]:
|
||||
"""
|
||||
@ -165,6 +169,7 @@ class Configuration(configparser.RawConfigParser):
|
||||
merge architecture specific sections into main configuration
|
||||
:param architecture: repository architecture
|
||||
"""
|
||||
self.architecture = architecture
|
||||
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
|
||||
# get overrides
|
||||
specific = self.section_name(section, architecture)
|
||||
@ -180,6 +185,15 @@ class Configuration(configparser.RawConfigParser):
|
||||
continue
|
||||
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:
|
||||
"""
|
||||
set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist
|
||||
|
@ -77,6 +77,11 @@ class Client:
|
||||
"""
|
||||
return BuildStatus()
|
||||
|
||||
def reload_auth(self) -> None:
|
||||
"""
|
||||
reload authentication module call
|
||||
"""
|
||||
|
||||
def remove(self, base: str) -> None:
|
||||
"""
|
||||
remove packages from watcher
|
||||
|
@ -67,6 +67,13 @@ class WebClient(Client):
|
||||
"""
|
||||
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
|
||||
def _status_url(self) -> str:
|
||||
"""
|
||||
@ -191,6 +198,18 @@ class WebClient(Client):
|
||||
self.logger.exception("could not get service status")
|
||||
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:
|
||||
"""
|
||||
remove packages from watcher
|
||||
|
@ -22,6 +22,7 @@ from pathlib import Path
|
||||
|
||||
from ahriman.web.views.index import IndexView
|
||||
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.search import SearchView
|
||||
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/reload-auth reload authentication module
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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/reload-auth", ReloadAuthView)
|
||||
|
||||
application.router.add_post("/service-api/v1/remove", RemoveView)
|
||||
|
||||
application.router.add_get("/service-api/v1/search", SearchView, allow_head=False)
|
||||
|
@ -21,6 +21,7 @@ from aiohttp.web import View
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ahriman.core.auth.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.status.watcher import Watcher
|
||||
|
||||
@ -30,6 +31,14 @@ class BaseView(View):
|
||||
base web view to make things typed
|
||||
"""
|
||||
|
||||
@property
|
||||
def configuration(self) -> Configuration:
|
||||
"""
|
||||
:return: configuration instance
|
||||
"""
|
||||
configuration: Configuration = self.request.app["configuration"]
|
||||
return configuration
|
||||
|
||||
@property
|
||||
def service(self) -> Watcher:
|
||||
"""
|
||||
|
40
src/ahriman/web/views/service/reload_auth.py
Normal file
40
src/ahriman/web/views/service/reload_auth.py
Normal 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()
|
@ -21,6 +21,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
|
||||
args.password = "pa55w0rd"
|
||||
args.access = UserAccess.Read
|
||||
args.as_service = False
|
||||
args.no_reload = False
|
||||
args.remove = False
|
||||
return args
|
||||
|
||||
@ -30,11 +31,13 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
|
||||
must run command
|
||||
"""
|
||||
args = _default_args(args)
|
||||
mocker.patch("pathlib.Path.mkdir")
|
||||
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")
|
||||
reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth")
|
||||
|
||||
User.run(args, "x86_64", configuration, True)
|
||||
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()
|
||||
get_salt_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:
|
||||
@ -50,14 +54,32 @@ def test_run_remove(args: argparse.Namespace, configuration: Configuration, mock
|
||||
"""
|
||||
args = _default_args(args)
|
||||
args.remove = True
|
||||
mocker.patch("pathlib.Path.mkdir")
|
||||
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")
|
||||
reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth")
|
||||
|
||||
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()
|
||||
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:
|
||||
|
@ -61,6 +61,13 @@ def test_get_self(client: Client) -> None:
|
||||
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:
|
||||
"""
|
||||
must process remove without errors
|
||||
|
@ -230,6 +230,32 @@ def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture
|
||||
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:
|
||||
"""
|
||||
must process package removal
|
||||
|
@ -5,6 +5,7 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.exceptions import InitializeException
|
||||
|
||||
|
||||
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")
|
||||
assert configuration.getlist("build", "test_list") == ["a"]
|
||||
assert configuration.getlist("build", "test_list") == ["a"]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
must set option correctly
|
||||
|
@ -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"])
|
@ -6,19 +6,19 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
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"]})
|
||||
|
||||
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:
|
||||
"""
|
||||
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")
|
||||
|
||||
assert response.status == 400
|
||||
add_mock.assert_not_called()
|
||||
remove_mock.assert_not_called()
|
||||
|
@ -5,6 +5,13 @@ from multidict import MultiDict
|
||||
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:
|
||||
"""
|
||||
must return service
|
||||
|
Loading…
Reference in New Issue
Block a user