add package request endpoint

This commit is contained in:
Evgenii Alekseev 2021-10-01 08:58:50 +03:00
parent 13d00c6f66
commit 73a4cee257
14 changed files with 1792 additions and 1618 deletions

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 326 KiB

View File

@ -148,6 +148,7 @@ Some features require optional dependencies to be installed:
Web application requires the following python packages to be installed:
* Core part requires `aiohttp` (application itself), `aiohttp_jinja2` and `Jinja2` (HTML generation from templates).
* In addition, `aiohttp_debugtoolbar` is required for debug panel. Please note that this option does not work together with authorization and basically must not be used in production.
* In addition, authorization feature requires `aiohttp_security`, `aiohttp_session` and `cryptography`.
* In addition to base authorization dependencies, OAuth2 also requires `aioauth-client` library.
@ -173,9 +174,9 @@ Package provides base jinja templates which can be overridden by settings. Vanil
## Requests and scopes
Service provides optional authorization which can be turned on in settings. In order to control user access there are two levels of authorization - read-only (only GET-like requests) and write (anything).
Service provides optional authorization which can be turned on in settings. In order to control user access there are two levels of authorization - read-only (only GET-like requests) and write (anything) which are provided by each web view directly.
If this feature is configured any request except for whitelisted will be prohibited without authentication. In addition, configuration flag `auth.allow_read_only` can be used in order to allow seeing main page without authorization (this page is in default white list).
If this feature is configured any request will be prohibited without authentication. In addition, configuration flag `auth.safe_build_status` can be used in order to allow seeing main page without authorization.
For authenticated users it uses encrypted session cookies to store tokens; encryption key is generated each time at the start of the application. It also stores expiration time of the session inside.

View File

@ -23,12 +23,12 @@ libalpm and AUR related configuration.
Base authorization settings. `OAuth` provider requires `aioauth-client` library to be installed.
* `target` - specifies authorization provider, string, optional, default `disabled`. Allowed values are `disabled`, `configuration`, `oauth`.
* `allow_read_only` - allow requesting read only pages without authorization, boolean, required.
* `client_id` - OAuth2 application client ID, string, required in case if `oauth` is used.
* `client_secret` - OAuth2 application client secret key, string, required in case if `oauth` is used.
* `max_age` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days.
* `oauth_provider` - OAuth2 provider class name as is in `aioauth-client` (e.g. `GoogleClient`, `GithubClient` etc), string, required in case if `oauth` is used.
* `oauth_scopes` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. `https://www.googleapis.com/auth/userinfo.email` for `GoogleClient` or `user:email` for `GithubClient`, space separated list of strings, required in case if `oauth` is used.
* `safe_build_status` - allow requesting status page without authorization, boolean, required.
* `salt` - password hash salt, string, required in case if authorization enabled (automatically generated by `create-user` subcommand).
## `auth:*` groups

View File

@ -10,10 +10,10 @@ root = /
[auth]
target = disabled
allow_read_only = yes
max_age = 604800
oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email
safe_build_status = yes
[build]
archbuild_flags =

View File

@ -16,6 +16,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" onclick="requestPackages()">Request</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()">Add</button>
</div>
</div>

View File

@ -80,6 +80,11 @@
doPackageAction("/service-api/v1/add", $packages);
}
function requestPackages() {
const $packages = [$package.val()]
doPackageAction("/service-api/v1/request", $packages);
}
function removePackages() { doPackageAction("/service-api/v1/remove", getSelection()); }
function updatePackages() { doPackageAction("/service-api/v1/add", getSelection()); }

View File

@ -33,9 +33,9 @@ from ahriman.models.user_access import UserAccess
class Auth:
"""
helper to deal with user authorization
:ivar allow_read_only: allow read only access to the index page
:ivar enabled: indicates if authorization is enabled
:ivar max_age: session age in seconds. It will be used for both client side and server side checks
:ivar safe_build_status: allow read only access to the index page
"""
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
@ -46,7 +46,7 @@ class Auth:
"""
self.logger = logging.getLogger("http")
self.allow_read_only = configuration.getboolean("auth", "allow_read_only")
self.safe_build_status = configuration.getboolean("auth", "safe_build_status")
self.enabled = provider.is_enabled
self.max_age = configuration.getint("auth", "max_age", fallback=7 * 24 * 3600)

View File

@ -24,6 +24,7 @@ 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.request import RequestView
from ahriman.web.views.service.search import SearchView
from ahriman.web.views.status.ahriman import AhrimanView
from ahriman.web.views.status.package import PackageView
@ -48,6 +49,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
POST /service-api/v1/remove remove existing package from repository
POST /service-api/v1/request request to add new packages to repository
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
@ -82,6 +85,8 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/service-api/v1/remove", RemoveView)
application.router.add_post("/service-api/v1/request", RequestView)
application.router.add_get("/service-api/v1/search", SearchView, allow_head=False)
application.router.add_post("/service-api/v1/update", AddView)

View File

@ -93,8 +93,9 @@ class IndexView(BaseView):
# auth block
auth_username = await authorized_userid(self.request)
authenticated = not self.validator.enabled or self.validator.safe_build_status or auth_username is not None
auth = {
"authenticated": not self.validator.enabled or self.validator.allow_read_only or auth_username is not None,
"authenticated": authenticated,
"control": self.validator.auth_control,
"enabled": self.validator.enabled,
"username": auth_username,

View File

@ -37,8 +37,7 @@ class AddView(BaseView):
JSON body must be supplied, the following model is used:
{
"packages": "ahriman", # either list of packages or package name as in AUR
"build_now": true # optional flag which runs build
"packages": "ahriman" # either list of packages or package name as in AUR
}
:return: redirect to main page on success
@ -46,11 +45,10 @@ class AddView(BaseView):
data = await self.extract_data(["packages"])
try:
now = data.get("build_now", True)
packages = data["packages"]
except Exception as e:
return json_response(data=str(e), status=400)
self.spawner.packages_add(packages, now)
self.spawner.packages_add(packages, now=True)
raise HTTPFound("/")

View File

@ -0,0 +1,54 @@
#
# 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 HTTPFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class RequestView(BaseView):
"""
request package web view. It is actually the same as AddView, but without now
:cvar POST_PERMISSION: post permissions of self
"""
POST_PERMISSION = UserAccess.Read
async def post(self) -> Response:
"""
request to add new package
JSON body must be supplied, the following model is used:
{
"packages": "ahriman" # either list of packages or package name as in AUR
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
packages = data["packages"]
except Exception as e:
return json_response(data=str(e), status=400)
self.spawner.packages_add(packages, now=False)
raise HTTPFound("/")

View File

@ -24,18 +24,7 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None:
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_with(["ahriman"], True)
async def test_post_now(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post and run build
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"], "build_now": False})
assert response.ok
add_mock.assert_called_with(["ahriman"], False)
add_mock.assert_called_with(["ahriman"], now=True)
async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None:
@ -57,4 +46,4 @@ async def test_post_update(client: TestClient, mocker: MockerFixture) -> None:
response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_with(["ahriman"], True)
add_mock.assert_called_with(["ahriman"], now=True)

View File

@ -0,0 +1,38 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from ahriman.models.user_access import UserAccess
from ahriman.web.views.service.request import RequestView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await RequestView.get_permission(request) == UserAccess.Read
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add")
response = await client.post("/service-api/v1/request", json={"packages": ["ahriman"]})
assert response.ok
add_mock.assert_called_with(["ahriman"], now=False)
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_add")
response = await client.post("/service-api/v1/request")
assert response.status == 400
add_mock.assert_not_called()

View File

@ -9,12 +9,12 @@ repositories = core extra community multilib
root = /
[auth]
allow_read_only = no
client_id = client_id
client_secret = client_secret
oauth_provider = GoogleClient
oauth_scopes = https://www.googleapis.com/auth/userinfo.email
salt = salt
safe_build_status = no
[build]
archbuild_flags =