diff --git a/modules/weko-admin/tests/conftest.py b/modules/weko-admin/tests/conftest.py index 9e691c5f73..d0b8416262 100644 --- a/modules/weko-admin/tests/conftest.py +++ b/modules/weko-admin/tests/conftest.py @@ -74,7 +74,8 @@ from weko_index_tree.models import Index, IndexStyle from weko_items_ui.config import WEKO_ITEMS_UI_CRIS_LINKAGE_RESEARCHMAP_MERGE_MODE_DEFAULT from weko_records_ui import WekoRecordsUI -from weko_records_ui.config import WEKO_PERMISSION_SUPER_ROLE_USER +from weko_records_ui.config import WEKO_PERMISSION_SUPER_ROLE_USER, \ + WEKO_PERMISSION_ROLE_COMMUNITY from weko_records import WekoRecords from weko_records.models import SiteLicenseInfo, SiteLicenseIpAddress,ItemType,ItemTypeName,ItemTypeJsonldMapping from weko_redis.redis import RedisConnection @@ -175,6 +176,7 @@ def base_app(instance_path, cache_config,request ,search_class): WEKO_ADMIN_RESTRICTED_ACCESS_SETTINGS = WEKO_ADMIN_RESTRICTED_ACCESS_SETTINGS, WEKO_WORKFLOW_USAGE_REPORT_WORKFLOW_NAME = 'test workflow31001', WEKO_PERMISSION_SUPER_ROLE_USER=WEKO_PERMISSION_SUPER_ROLE_USER, + WEKO_PERMISSION_ROLE_COMMUNITY=WEKO_PERMISSION_ROLE_COMMUNITY, WEKO_ITEMS_UI_CRIS_LINKAGE_RESEARCHMAP_MERGE_MODE_DEFAULT=WEKO_ITEMS_UI_CRIS_LINKAGE_RESEARCHMAP_MERGE_MODE_DEFAULT ) app_.testing = True diff --git a/modules/weko-admin/tests/test_permissions.py b/modules/weko-admin/tests/test_permissions.py index 87136f1ce5..57d76cd37b 100644 --- a/modules/weko-admin/tests/test_permissions.py +++ b/modules/weko-admin/tests/test_permissions.py @@ -1,8 +1,12 @@ import pkg_resources -from mock import patch +import pytest +from mock import MagicMock, patch +from flask_login import login_user +from werkzeug.exceptions import Forbidden, NotFound, Unauthorized -from weko_admin.permissions import admin_permission_factory +from weko_admin.permissions import _lookup_param, \ + admin_permission_factory, repository_scope_required # .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp @@ -13,8 +17,334 @@ def test_admin_permission_factory(app, users): result = admin_permission_factory(action) permission_values = [permission.value for permission in list(result.needs)] assert action in permission_values - + with patch("weko_admin.permissions.pkg_resources.get_distribution", side_effect=pkg_resources.DistributionNotFound): result = admin_permission_factory(action) permission_values = [permission.value for permission in list(result.needs)] assert action in permission_values + + +# def repository_scope_required(...): +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_anonymous -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_anonymous(app): + """未ログインの場合は401になること。""" + @repository_scope_required(repository_id_param='repo_id') + def view(*args, **kwargs): + return 'ok' + + with app.test_request_context('/?repo_id=repoA'): + with pytest.raises(Unauthorized): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_super_user -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +@pytest.mark.parametrize("index", [0, 1]) # sysadmin, repoadmin +def test_repository_scope_required_super_user(app, users, index): + """System/Repository Administratorは無条件で許可されること。""" + @repository_scope_required(repository_id_param='repo_id') + def view(*args, **kwargs): + return 'ok' + + with app.test_request_context('/?repo_id=repoA'): + login_user(users[index]["obj"]) + assert view() == 'ok' + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_no_role -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_no_role(app, users): + """ロールなしログインユーザーは拒否されること。""" + @repository_scope_required(repository_id_param='repo_id') + def view(*args, **kwargs): + return 'ok' + + with app.test_request_context('/?repo_id=repoA'): + login_user(users[4]["obj"]) # generaluser + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_community_admin_allowed -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_community_admin_allowed(app, users): + """担当コミュニティのCommunity Administratorは許可されること。""" + @repository_scope_required(repository_id_param='repo_id') + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/?repo_id=repoA'): + login_user(users[2]["obj"]) # comadmin + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + assert view() == 'ok' + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_community_admin_denied -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_community_admin_denied(app, users): + """担当外コミュニティのCommunity Administratorは拒否されること。""" + @repository_scope_required(repository_id_param='repo_id') + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoB') + with app.test_request_context('/?repo_id=repoA'): + login_user(users[2]["obj"]) # comadmin(repoBのみ担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_both_params_required -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_both_params_required(app, users): + """DB側とbody側の両方が取得できる場合は双方が担当範囲内であることを要求すること。""" + record = MagicMock(repository_id='repoA') + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = record + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + # DB側(repoA)は担当内だが、bodyで担当外のrepoBへの移動が指定されているため拒否 + with app.test_request_context('/?repository_id=repoB&page_id=1'): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + with pytest.raises(Forbidden): + view() + id_model.query.filter_by.assert_called_with(id='1') + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_db_out_of_scope -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_db_out_of_scope(app, users): + """DB側が担当外の場合はbody側が担当内でも拒否されること。""" + record = MagicMock(repository_id='repoB') + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = record + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/?repository_id=repoA&page_id=1'): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_both_in_scope -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_both_in_scope(app, users): + """DB側・body側とも担当内(同一ID)の場合は許可されること。""" + record = MagicMock(repository_id='repoA') + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = record + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/?repository_id=repoA&page_id=1'): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + assert view() == 'ok' + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_move_within_scope -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_move_within_scope(app, users): + """複数コミュニティ担当者による担当内から担当内への移動は許可されること。""" + record = MagicMock(repository_id='repoA') + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = record + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + communities = [MagicMock(id='repoA'), MagicMock(id='repoB')] + with app.test_request_context('/?repository_id=repoB&page_id=1'): + login_user(users[2]["obj"]) # comadmin(repoA/repoBを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=communities): + assert view() == 'ok' + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_id_param_only -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +@pytest.mark.parametrize("db_repository_id, allowed", [('repoA', True), ('repoB', False)]) +def test_repository_scope_required_id_param_only(app, users, db_repository_id, allowed): + """id_paramのみ値がある場合(削除系)はDB側の値だけで判定すること。""" + record = MagicMock(repository_id=db_repository_id) + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = record + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/?page_id=1'): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + if allowed: + assert view() == 'ok' + else: + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_repository_id_param_only -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +@pytest.mark.parametrize("body_repository_id, allowed", [('repoA', True), ('repoB', False)]) +def test_repository_scope_required_repository_id_param_only(app, users, + body_repository_id, allowed): + """repository_id_paramのみ値がある場合(新規作成)はbody側の値だけで判定すること。""" + id_model = MagicMock() + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/?repository_id={}'.format(body_repository_id)): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + if allowed: + assert view() == 'ok' + else: + with pytest.raises(Forbidden): + view() + id_model.query.filter_by.assert_not_called() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_no_params -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_no_params(app, users): + """id_param/repository_id_paramとも値がない場合は拒否されること。""" + id_model = MagicMock() + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/'): + login_user(users[2]["obj"]) # comadmin + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_db_repository_id_none -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_db_repository_id_none(app, users): + """DBレコードのrepository_idがNoneの場合はbody側が担当内でも拒否されること。""" + record = MagicMock(repository_id=None) + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = record + + @repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context('/?repository_id=repoA&page_id=1'): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_nested_param -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +@pytest.mark.parametrize("body_repository_id, allowed", [('repoA', True), ('repoB', False)]) +def test_repository_scope_required_nested_param(app, users, body_repository_id, allowed): + """repository_id_paramにドット記法を指定した場合はネストした値で判定すること。""" + @repository_scope_required(repository_id_param='data.repository') + def view(*args, **kwargs): + return 'ok' + + community = MagicMock(id='repoA') + with app.test_request_context( + '/', json={'data': {'repository': body_repository_id}}): + login_user(users[2]["obj"]) # comadmin(repoAを担当) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + if allowed: + assert view() == 'ok' + else: + with pytest.raises(Forbidden): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_id_param_not_found -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_id_param_not_found(app, users): + """id_paramで指定したレコードが存在しない場合は404になること。""" + id_model = MagicMock() + id_model.query.filter_by.return_value.one_or_none.return_value = None + + @repository_scope_required(id_param='page_id', id_model=id_model) + def view(*args, **kwargs): + return 'ok' + + with app.test_request_context('/?page_id=999'): + login_user(users[2]["obj"]) # comadmin + with pytest.raises(NotFound): + view() + + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_repository_scope_required_no_repository_id -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_repository_scope_required_no_repository_id(app, users): + """repository_idが取得できない場合は拒否されること。""" + @repository_scope_required(repository_id_param='repo_id') + def view(*args, **kwargs): + return 'ok' + + with app.test_request_context('/'): + login_user(users[2]["obj"]) # comadmin + with pytest.raises(Forbidden): + view() + + +# def _lookup_param(data, kwargs, path): +# .tox/c1/bin/pytest --cov=weko_admin tests/test_permissions.py::test_lookup_param -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +@pytest.mark.parametrize("data, kwargs, path, expected", [ + # ドット無し: dataを優先 + ({'repo_id': 'repoA'}, {'repo_id': 'repoB'}, 'repo_id', 'repoA'), + # ドット無し: dataに無ければkwargsにフォールバック + ({}, {'repo_id': 'repoB'}, 'repo_id', 'repoB'), + # ドット無し: dataの値がfalsyならkwargsにフォールバック + ({'repo_id': ''}, {'repo_id': 'repoB'}, 'repo_id', 'repoB'), + # ドット無し: どちらにも無い + ({}, {}, 'repo_id', None), + # ドット記法: ネストした値を取得 + ({'data': {'repository': 'repoA'}}, {}, 'data.repository', 'repoA'), + # ドット記法: 途中がdictでない + ({'data': 'foo'}, {}, 'data.repository', None), + ({'data': None}, {}, 'data.repository', None), + ({'data': [1, 2]}, {}, 'data.repository', None), + # ドット記法: キーが存在しない + ({'data': {}}, {}, 'data.repository', None), + ({}, {}, 'data.repository', None), + # ドット記法: 3階層 + ({'a': {'b': {'c': 'repoA'}}}, {}, 'a.b.c', 'repoA'), + ({'a': {'b': {}}}, {}, 'a.b.c', None), + # pathが未指定 + ({'repo_id': 'repoA'}, {}, None, None), + ({'repo_id': 'repoA'}, {}, '', None), +]) +def test_lookup_param(data, kwargs, path, expected): + """_lookup_paramがdata/kwargs/ドット記法から正しく値を取得すること。""" + assert _lookup_param(data, kwargs, path) == expected diff --git a/modules/weko-admin/tests/test_views.py b/modules/weko-admin/tests/test_views.py index 6ecc03d2a2..106eed46c7 100644 --- a/modules/weko-admin/tests/test_views.py +++ b/modules/weko-admin/tests/test_views.py @@ -517,9 +517,10 @@ def test_get_feedback_mail(api, users): #def get_send_mail_history(): # .tox/c1/bin/pytest --cov=weko_admin tests/test_views.py::test_get_send_mail_history -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp -def test_get_send_mail_history(api, mocker): +def test_get_send_mail_history(api, users, mocker): mocker.patch("weko_admin.views.FeedbackMail.load_feedback_mail_history",side_effect=lambda x, y:{"page":x}) url = url_for("weko_admin.get_send_mail_history") + login_user_via_session(client=api, email=users[0]["email"]) # sysadmin input = {"page":2, "repo_id":"Root Index"} res = api.get(url,query_string=input) assert response_data(res) == {"page":2} @@ -528,6 +529,29 @@ def test_get_send_mail_history(api, mocker): res = api.get(url,query_string=input) assert response_data(res) == {"page":1} +# .tox/c1/bin/pytest --cov=weko_admin tests/test_views.py::test_get_send_mail_history_guest -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +def test_get_send_mail_history_guest(api, mocker): + """未ログインの場合は401になること。""" + mocker.patch("weko_admin.views.FeedbackMail.load_feedback_mail_history",side_effect=lambda x, y:{"page":x}) + url = url_for("weko_admin.get_send_mail_history") + res = api.get(url, query_string={"page":1, "repo_id":"Root Index"}) + assert res.status_code == 401 + +# .tox/c1/bin/pytest --cov=weko_admin tests/test_views.py::test_get_send_mail_history_scope -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp +@pytest.mark.parametrize("index,repo_id,is_permission",[ + (2, "comm1", True), # comadmin(担当コミュニティ) + (2, "other_repo", False), # comadmin(担当外コミュニティ) + (3, "comm1", False), # contributor(コミュニティ管理者ロールなし) + (4, "comm1", False), # generaluser(ロールなし) + ]) +def test_get_send_mail_history_scope(api, users, community, index, repo_id, is_permission, mocker): + """repository_scope_requiredによるコミュニティ管理者のスコープ制御を確認する。""" + mocker.patch("weko_admin.views.FeedbackMail.load_feedback_mail_history",side_effect=lambda x, y:{"page":x}) + login_user_via_session(client=api, email=users[index]["email"]) + url = url_for("weko_admin.get_send_mail_history") + res = api.get(url, query_string={"page":1, "repo_id":repo_id}) + assert_role(res, is_permission) + #def get_failed_mail(): # .tox/c1/bin/pytest --cov=weko_admin tests/test_views.py::test_get_failed_mail_acl -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-admin/.tox/c1/tmp diff --git a/modules/weko-admin/weko_admin/permissions.py b/modules/weko-admin/weko_admin/permissions.py index f23dd0cfcc..0dd37c4102 100644 --- a/modules/weko-admin/weko_admin/permissions.py +++ b/modules/weko-admin/weko_admin/permissions.py @@ -20,7 +20,11 @@ """WEKO3 module docstring.""" +from functools import wraps + import pkg_resources +from flask import abort, current_app, request +from flask_login import current_user from flask_principal import ActionNeed from invenio_access import Permission, action_factory @@ -55,3 +59,88 @@ def admin_permission_factory(action): from flask_principal import Permission return Permission(action_class) + + +def _is_super_user(user): + """Check whether the user has a system/repository administrator role.""" + supers = current_app.config['WEKO_PERMISSION_SUPER_ROLE_USER'] + return any(role.name in supers for role in (user.roles or [])) + + +def _is_community_admin(user): + """Check whether the user has a community administrator role.""" + comadmin = current_app.config['WEKO_PERMISSION_ROLE_COMMUNITY'] + return any(role.name in comadmin for role in (user.roles or [])) + + +def _lookup_param(data, kwargs, path): + """リクエストデータまたはURL変数からパラメータ値を取得する。 + + pathにドットが含まれる場合はネストしたdictを辿る + (例: 'data.repository')。途中がdictでない、またはキーが + 存在しない場合はNoneを返す。 + """ + if not path: + return None + if '.' not in path: + return data.get(path) or kwargs.get(path) + value = data + for key in path.split('.'): + if not isinstance(value, dict) or key not in value: + return None + value = value[key] + return value + + +def repository_scope_required(repository_id_param=None, + id_param=None, id_model=None, id_attr='repository_id', + pk_attr='id'): + """repository_id(またはid_param経由でid_modelから解決したid_attr)が + current_userの担当範囲内であることを要求する。 + + - System/Repository Administrator: 無条件許可 + - Community Administrator: 担当コミュニティ(Community.get_repositories_by_user)のみ許可 + - id_param(既存レコード=移動元)とrepository_id_param(リクエスト指定=移動先)の + 両方の値が取得できた場合は、双方が担当範囲内であることを要求する + (担当外コミュニティへのレコード移動を防ぐ)。片方しか取得できない場合は + 取得できた方だけで判定する + - repository_id_param / id_param にはドット記法でネストしたキーを指定できる + (例: 'data.repository') + - pk_attr: id_modelの主キー列名。デフォルトは'id'。主キーが異なる場合は + 明示的に指定する(例: WidgetItemはid列を持たず主キーはwidget_id) + """ + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + if not current_user.is_authenticated: + abort(401) + if _is_super_user(current_user): + return f(*args, **kwargs) + + data = request.get_json(silent=True) or request.form or request.args + repository_ids = [] + + target_id = _lookup_param(data, kwargs, id_param) + if target_id: + record = id_model.query.filter_by(**{pk_attr: target_id}).one_or_none() + if record is None: + abort(404) + # 移動元(既存レコードの現在の所属) + repository_ids.append(getattr(record, id_attr, None)) + requested_repository_id = _lookup_param(data, kwargs, repository_id_param) + if requested_repository_id: + # 移動先(リクエストで指定された所属) + repository_ids.append(requested_repository_id) + + if _is_community_admin(current_user) and repository_ids: + # weko_admin初期化時のimportで循環参照が発生するため、 + # get_repository_list()と同様に関数内でimportする。 + from invenio_communities.models import Community + communities = Community.get_repositories_by_user(current_user) + community_ids = {str(c.id) for c in communities} + if all(str(rid) in community_ids for rid in repository_ids): + return f(*args, **kwargs) + + abort(403) + return wrapped + return decorator diff --git a/modules/weko-admin/weko_admin/views.py b/modules/weko-admin/weko_admin/views.py index 69c5f96e01..60df4d954e 100644 --- a/modules/weko-admin/weko_admin/views.py +++ b/modules/weko-admin/weko_admin/views.py @@ -48,6 +48,7 @@ from .api import send_site_license_mail from .config import WEKO_ADMIN_PERMISSION_ROLE_REPO, \ WEKO_ADMIN_PERMISSION_ROLE_SYSTEM, WEKO_ADMIN_PERMISSION_ROLE_COMMUNITY +from .permissions import repository_scope_required from .models import FacetSearchSetting, SessionLifetime, SiteInfo, AdminSettings from .utils import FeedbackMail, StatisticMail, UsageReport, \ format_site_info_data, get_admin_lang_setting, \ @@ -471,6 +472,7 @@ def get_feedback_mail(): @blueprint_api.route('/get_send_mail_history', methods=['GET']) +@repository_scope_required(repository_id_param='repo_id') def get_send_mail_history(): """API allow to get send mail history. diff --git a/modules/weko-gridlayout/tests/conftest.py b/modules/weko-gridlayout/tests/conftest.py index f872aed7d9..517a80f2a6 100644 --- a/modules/weko-gridlayout/tests/conftest.py +++ b/modules/weko-gridlayout/tests/conftest.py @@ -46,7 +46,8 @@ from weko_records.models import ItemTypeProperty from weko_records.models import ItemType, ItemTypeMapping, ItemTypeName from weko_records.api import Mapping -from weko_records_ui.config import WEKO_PERMISSION_SUPER_ROLE_USER +from weko_records_ui.config import WEKO_PERMISSION_SUPER_ROLE_USER, \ + WEKO_PERMISSION_ROLE_COMMUNITY from weko_index_tree.models import Index from weko_gridlayout import WekoGridLayout #from weko_admin import WekoAdmin @@ -119,7 +120,8 @@ def base_app(instance_path): FILES_REST_DEFAULT_MAX_FILE_SIZE=None, FILES_REST_OBJECT_KEY_MAX_LEN=255, BABEL_DEFAULT_TIMEZONE='Asia/Tokyo', - WEKO_PERMISSION_SUPER_ROLE_USER=WEKO_PERMISSION_SUPER_ROLE_USER + WEKO_PERMISSION_SUPER_ROLE_USER=WEKO_PERMISSION_SUPER_ROLE_USER, + WEKO_PERMISSION_ROLE_COMMUNITY=WEKO_PERMISSION_ROLE_COMMUNITY ) Babel(app_) InvenioDB(app_) diff --git a/modules/weko-gridlayout/tests/test_views.py b/modules/weko-gridlayout/tests/test_views.py index aa7932184f..91124ad3cd 100644 --- a/modules/weko-gridlayout/tests/test_views.py +++ b/modules/weko-gridlayout/tests/test_views.py @@ -7,7 +7,7 @@ from invenio_cache import current_cache from invenio_accounts.testutils import login_user_via_session -from weko_gridlayout.models import WidgetDesignPage,WidgetDesignSetting +from weko_gridlayout.models import WidgetDesignPage,WidgetDesignSetting,WidgetItem # The endpoints these cases cover carry @login_required and nothing else # (weko_gridlayout/views.py), so every signed-in user reaches them. The 403s @@ -21,6 +21,24 @@ (4, 200), ] +# users indices: 0=contributor, 1=repoadmin, 2=sysadmin, 3=comadmin, +# 4=generaluser (see conftest.users fixture). +# +# load_widget_list_design_setting/save_widget_layout_setting/ +# save_widget_design_page/delete_widget_design_page/delete_widget_item are +# now protected by weko_admin.permissions.repository_scope_required. These +# requests carry no repository_id/page_id/data_id in the body, so +# repository_id cannot be resolved: System/Repository Administrator are +# still allowed unconditionally, but Contributor/Community Administrator/ +# no-role users are rejected because scope cannot be confirmed. +user_results_repo_scope_no_target = [ + (0, 403), + (1, 200), + (2, 200), + (3, 403), + (4, 403), +] + # def preload_pages(): def test_preload_pages(i18n_app, db): @@ -58,7 +76,7 @@ def test_unlocked_widget_guest(client, users): assert res.status_code == 302 -@pytest.mark.parametrize('id, status_code', user_results1) +@pytest.mark.parametrize('id, status_code', user_results_repo_scope_no_target) def test_save_widget_layout_setting_login(client, users, id, status_code): login_user_via_session(client=client, email=users[id]["email"]) with patch("weko_gridlayout.views.WidgetDesignServices.update_widget_design_setting", return_value={}): @@ -76,7 +94,10 @@ def test_save_widget_layout_setting_guest(client, users): assert res.status_code == 302 -@pytest.mark.parametrize('id, status_code', user_results1) +# save_widget_item is now protected by repository_scope_required as well. +# This request carries no data_id/data.repository, so the scope cannot be +# resolved and only System/Repository Administrator are allowed. +@pytest.mark.parametrize('id, status_code', user_results_repo_scope_no_target) def test_save_widget_item_login(client, users, id, status_code): login_user_via_session(client=client, email=users[id]["email"]) with patch("weko_gridlayout.views.WidgetItemServices.save_command", return_value={}): @@ -94,7 +115,99 @@ def test_save_widget_item_guest(client, users): assert res.status_code == 302 -@pytest.mark.parametrize('id, status_code', user_results1) +# save_widget_item scope check (repository_scope_required). +# data_id -> the widget being updated (source repository, taken from DB), +# data.repository -> the repository requested by the client (destination). +# .tox/c1/bin/pytest --cov=weko_gridlayout tests/test_views.py -k save_widget_item_scope -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-gridlayout/.tox/c1/tmp +def test_save_widget_item_scope_no_role(client, users, widget_item): + """ロールなしログインユーザーは拒否されること。""" + login_user_via_session(client=client, email=users[4]["email"]) + with patch("weko_gridlayout.views.WidgetItemServices.save_command", return_value={}): + res = client.post( + "/admin/save_widget_item", + data=json.dumps({"flag_edit": False, + "data": {"repository": "Root Index"}}), + content_type="application/json") + assert res.status_code == 403 + + +@pytest.mark.parametrize('id', [1, 2]) # repoadmin, sysadmin +def test_save_widget_item_scope_super_user(client, users, widget_item, id): + """System/Repository Administratorは新規作成・更新とも許可されること。""" + login_user_via_session(client=client, email=users[id]["email"]) + with patch("weko_gridlayout.views.WidgetItemServices.save_command", return_value={}): + # create + res = client.post( + "/admin/save_widget_item", + data=json.dumps({"flag_edit": False, + "data": {"repository": "Root Index"}}), + content_type="application/json") + assert res.status_code == 200 + + # update + res = client.post( + "/admin/save_widget_item", + data=json.dumps({"flag_edit": True, + "data_id": widget_item[0].widget_id, + "data": {"repository": "Root Index"}}), + content_type="application/json") + assert res.status_code == 200 + + +@pytest.mark.parametrize('repository, status_code', + [("Root Index", 200), ("other", 403)]) +def test_save_widget_item_scope_comadmin_create(client, users, repository, + status_code): + """Community Administratorの新規作成は担当コミュニティのみ許可されること。""" + login_user_via_session(client=client, email=users[3]["email"]) # comadmin + community = MagicMock(id="Root Index") + with patch("weko_gridlayout.views.WidgetItemServices.save_command", return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + res = client.post( + "/admin/save_widget_item", + data=json.dumps({"flag_edit": False, + "data": {"repository": repository}}), + content_type="application/json") + assert res.status_code == status_code + + +@pytest.mark.parametrize('repository, status_code', + [("Root Index", 200), ("other", 403)]) +def test_save_widget_item_scope_comadmin_update(client, users, widget_item, + repository, status_code): + """担当内ウィジェットの更新は許可され、担当外への移動は拒否されること。""" + login_user_via_session(client=client, email=users[3]["email"]) # comadmin + community = MagicMock(id="Root Index") + with patch("weko_gridlayout.views.WidgetItemServices.save_command", return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + res = client.post( + "/admin/save_widget_item", + data=json.dumps({"flag_edit": True, + "data_id": widget_item[0].widget_id, + "data": {"repository": repository}}), + content_type="application/json") + assert res.status_code == status_code + + +def test_save_widget_item_scope_not_found(client, users, widget_item): + """存在しないdata_idを指定した場合は404になること。""" + login_user_via_session(client=client, email=users[3]["email"]) # comadmin + community = MagicMock(id="Root Index") + with patch("weko_gridlayout.views.WidgetItemServices.save_command", return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=[community]): + res = client.post( + "/admin/save_widget_item", + data=json.dumps({"flag_edit": True, + "data_id": 999999, + "data": {"repository": "Root Index"}}), + content_type="application/json") + assert res.status_code == 404 + + +@pytest.mark.parametrize('id, status_code', user_results_repo_scope_no_target) def test_save_widget_design_page_login(client, users, id, status_code): login_user_via_session(client=client, email=users[id]["email"]) with patch("weko_gridlayout.views.WidgetDesignPageServices.add_or_update_page", return_value={}): @@ -112,7 +225,7 @@ def test_save_widget_design_page_guest(client, users): assert res.status_code == 302 -@pytest.mark.parametrize('id, status_code', user_results1) +@pytest.mark.parametrize('id, status_code', user_results_repo_scope_no_target) def test_load_widget_list_design_setting_login(client, users, id, status_code): from weko_gridlayout import views from weko_gridlayout.services import WidgetDesignServices @@ -144,7 +257,11 @@ def test_load_widget_list_design_setting_issue50978(client, users): views.get_default_language = Mock() WidgetDesignServices.get_widget_list = Mock(return_value={}) WidgetDesignServices.get_widget_preview = Mock(return_value={}) - login_user_via_session(client=client, email=users[3]["email"]) + # Use a System Administrator here: load_widget_list_design_setting is now + # protected by repository_scope_required, and this test targets the + # view's own request-body validation (400), not the scope check, so it + # must bypass the scope check via the unconditional super-user path. + login_user_via_session(client=client, email=users[2]["email"]) # no request data res = client.post("/admin/load_widget_list_design_setting") @@ -291,7 +408,7 @@ def test_load_widget_design_page_issue50978(client, users): assert res4.status_code == 400 -@pytest.mark.parametrize('id, status_code', user_results1) +@pytest.mark.parametrize('id, status_code', user_results_repo_scope_no_target) def test_delete_widget_item_login(client, users, id, status_code): login_user_via_session(client=client, email=users[id]["email"]) with patch("weko_gridlayout.views.WidgetItemServices.delete_by_id", return_value={}): @@ -311,7 +428,10 @@ def test_delete_widget_item_guest(client, users): # .tox/c1/bin/pytest --cov=weko_gridlayout tests/test_views.py::test_load_widget_design_page_issue50978 -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp def test_delete_widget_item_issue50978(client, users): - login_user_via_session(client=client, email=users[3]["email"]) + # System Administrator: delete_widget_item is now protected by + # repository_scope_required and this test targets the view's own + # request-body validation (400), so it must bypass the scope check. + login_user_via_session(client=client, email=users[2]["email"]) with patch("weko_gridlayout.views.WidgetItemServices.delete_by_id", return_value={}): # no request data. The view reads request.headers['Content-Type'] # directly, so it needs the header even when there is no body. @@ -328,7 +448,7 @@ def test_delete_widget_item_issue50978(client, users): assert res4.status_code == 400 -@pytest.mark.parametrize('id, status_code', user_results1) +@pytest.mark.parametrize('id, status_code', user_results_repo_scope_no_target) def test_delete_widget_design_page_login(client, users, id, status_code): login_user_via_session(client=client, email=users[id]["email"]) with patch("weko_gridlayout.views.WidgetDesignPageServices.delete_page", return_value={}): @@ -348,7 +468,10 @@ def test_delete_widget_design_page_guest(client, users): # .tox/c1/bin/pytest --cov=weko_gridlayout tests/test_views.py::test_delete_widget_design_page_issue50978 -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp def test_delete_widget_design_page_issue50978(client, users): - login_user_via_session(client=client, email=users[3]["email"]) + # System Administrator: delete_widget_design_page is now protected by + # repository_scope_required and this test targets the view's own + # request-body validation (400), so it must bypass the scope check. + login_user_via_session(client=client, email=users[2]["email"]) with patch("weko_gridlayout.views.WidgetDesignPageServices.delete_page", return_value={}): # no request data res3 = client.post( @@ -435,7 +558,10 @@ def test_save_widget_design_page(client, users): # .tox/c1/bin/pytest --cov=weko_gridlayout tests/test_views.py::test_save_widget_design_page_issue50978 -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp def test_save_widget_design_page_issue50978(client, users): - login_user_via_session(client=client, email=users[3]["email"]) + # System Administrator: save_widget_design_page is now protected by + # repository_scope_required and this test targets the view's own + # request-body validation (400), so it must bypass the scope check. + login_user_via_session(client=client, email=users[2]["email"]) # no request data res3 = client.post( "/admin/save_widget_design_page", @@ -878,3 +1004,343 @@ def test_unlocked_widget_issue50978(client, users): content_type="application/json" ) assert res4.status_code == 400 + + +# --------------------------------------------------------------------------- +# repository_scope_required coverage +# +# no.283/284/288/289/292: load_widget_list_design_setting, +# save_widget_layout_setting, save_widget_design_page, +# delete_widget_design_page and delete_widget_item are now protected by +# weko_admin.permissions.repository_scope_required so that only +# System/Repository Administrator or the Community Administrator in charge +# of the target repository can operate on it. +# +# users indices (see conftest.users): 0=contributor, 1=repoadmin, +# 2=sysadmin, 3=comadmin, 4=generaluser, 7=plain user with no role. +# --------------------------------------------------------------------------- + +IN_SCOPE_COMMUNITY = [MagicMock(id='Root Index')] +OUT_OF_SCOPE_COMMUNITY = [MagicMock(id='OtherRepo')] + + +# def load_widget_list_design_setting(): +@pytest.mark.parametrize('id, status_code', [(1, 200), (2, 200)]) +def test_load_widget_list_design_setting_scope_super_user( + client, users, id, status_code): + """System/Repository Administratorは無条件で許可されること。""" + from weko_gridlayout.services import WidgetDesignServices + WidgetDesignServices.get_widget_list = Mock(return_value={}) + WidgetDesignServices.get_widget_preview = Mock(return_value={}) + login_user_via_session(client=client, email=users[id]["email"]) + res = client.post("/admin/load_widget_list_design_setting", + data=json.dumps({"repository_id": "Root Index"}), + content_type="application/json") + assert res.status_code == status_code + + +def test_load_widget_list_design_setting_scope_community_admin_allowed( + client, users): + """担当コミュニティのCommunity Administratorは許可されること。""" + from weko_gridlayout.services import WidgetDesignServices + WidgetDesignServices.get_widget_list = Mock(return_value={}) + WidgetDesignServices.get_widget_preview = Mock(return_value={}) + login_user_via_session(client=client, email=users[3]["email"]) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post("/admin/load_widget_list_design_setting", + data=json.dumps({"repository_id": "Root Index"}), + content_type="application/json") + assert res.status_code == 200 + + +def test_load_widget_list_design_setting_scope_community_admin_denied( + client, users): + """担当外コミュニティのCommunity Administratorは拒否されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=OUT_OF_SCOPE_COMMUNITY): + res = client.post("/admin/load_widget_list_design_setting", + data=json.dumps({"repository_id": "Root Index"}), + content_type="application/json") + assert res.status_code == 403 + + +def test_load_widget_list_design_setting_scope_no_role(client, users): + """ロールなしログインユーザーは拒否されること。""" + login_user_via_session(client=client, email=users[4]["email"]) + res = client.post("/admin/load_widget_list_design_setting", + data=json.dumps({"repository_id": "Root Index"}), + content_type="application/json") + assert res.status_code == 403 + + +def test_load_widget_list_design_setting_scope_anonymous(client, users): + """未ログインの場合はログイン画面へリダイレクトされること(login_requiredが先に働く)。""" + res = client.post("/admin/load_widget_list_design_setting", + data=json.dumps({"repository_id": "Root Index"}), + content_type="application/json") + assert res.status_code == 302 + + +# def save_widget_layout_setting(): +def test_save_widget_layout_setting_scope_community_admin_allowed( + client, users): + """担当コミュニティのCommunity Administratorは許可されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("weko_gridlayout.views.WidgetDesignServices.update_widget_design_setting", + return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post( + "/admin/save_widget_layout_setting", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 200 + + +def test_save_widget_layout_setting_scope_community_admin_denied( + client, users): + """担当外コミュニティのCommunity Administratorは拒否されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=OUT_OF_SCOPE_COMMUNITY): + res = client.post( + "/admin/save_widget_layout_setting", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 403 + + +def test_save_widget_layout_setting_scope_no_role(client, users): + """ロールなしログインユーザーは拒否されること。""" + login_user_via_session(client=client, email=users[4]["email"]) + res = client.post( + "/admin/save_widget_layout_setting", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 403 + + +def test_save_widget_layout_setting_scope_anonymous(client, users): + """未ログインの場合はログイン画面へリダイレクトされること(login_requiredが先に働く)。""" + res = client.post( + "/admin/save_widget_layout_setting", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 302 + + +def test_save_widget_layout_setting_scope_both_params_required( + client, users, db_register): + """既存ページ更新時はDB側とbody側の双方が担当範囲内であることを要求すること。 + + page_id=1の実データは'Root Index'(担当内)に属するが、bodyでは担当外の + OtherRepoが指定されているため拒否される。 + """ + login_user_via_session(client=client, email=users[3]["email"]) + with patch("weko_gridlayout.views.WidgetDesignServices.update_widget_design_setting", + return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post( + "/admin/save_widget_layout_setting", + data=json.dumps({"repository_id": "OtherRepo", "page_id": 1}), + content_type="application/json") + assert res.status_code == 403 + + +def test_save_widget_layout_setting_scope_id_param_not_found( + client, users, db_register): + """id_paramで指定したページが存在しない場合は404になること。""" + login_user_via_session(client=client, email=users[3]["email"]) + res = client.post( + "/admin/save_widget_layout_setting", + data=json.dumps({"repository_id": "Root Index", "page_id": 999}), + content_type="application/json") + assert res.status_code == 404 + + +# def save_widget_design_page(): +def test_save_widget_design_page_scope_community_admin_allowed( + client, users): + """担当コミュニティのCommunity Administratorは許可されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("weko_gridlayout.views.WidgetDesignPageServices.add_or_update_page", + return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post( + "/admin/save_widget_design_page", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 200 + + +def test_save_widget_design_page_scope_community_admin_denied(client, users): + """担当外コミュニティのCommunity Administratorは拒否されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=OUT_OF_SCOPE_COMMUNITY): + res = client.post( + "/admin/save_widget_design_page", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 403 + + +def test_save_widget_design_page_scope_no_role(client, users): + """ロールなしログインユーザーは拒否されること。""" + login_user_via_session(client=client, email=users[4]["email"]) + res = client.post( + "/admin/save_widget_design_page", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 403 + + +def test_save_widget_design_page_scope_anonymous(client, users): + """未ログインの場合はログイン画面へリダイレクトされること(login_requiredが先に働く)。""" + res = client.post( + "/admin/save_widget_design_page", + data=json.dumps({"repository_id": "Root Index", "page_id": 0}), + content_type="application/json") + assert res.status_code == 302 + + +def test_save_widget_design_page_scope_both_params_required( + client, users, db_register): + """既存ページ更新時はDB側とbody側の双方が担当範囲内であることを要求すること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("weko_gridlayout.views.WidgetDesignPageServices.add_or_update_page", + return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post( + "/admin/save_widget_design_page", + data=json.dumps({"repository_id": "OtherRepo", "page_id": 1}), + content_type="application/json") + assert res.status_code == 403 + + +# def delete_widget_design_page(): +def test_delete_widget_design_page_scope_community_admin_allowed( + client, users, db_register): + """担当コミュニティのCommunity Administratorは許可されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("weko_gridlayout.views.WidgetDesignPageServices.delete_page", + return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post( + "/admin/delete_widget_design_page", + data=json.dumps({"page_id": 1}), + content_type="application/json") + assert res.status_code == 200 + + +def test_delete_widget_design_page_scope_community_admin_denied( + client, users, db_register): + """担当外コミュニティのCommunity Administratorは拒否されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=OUT_OF_SCOPE_COMMUNITY): + res = client.post( + "/admin/delete_widget_design_page", + data=json.dumps({"page_id": 1}), + content_type="application/json") + assert res.status_code == 403 + + +def test_delete_widget_design_page_scope_no_role(client, users, db_register): + """ロールなしログインユーザーは拒否されること。""" + login_user_via_session(client=client, email=users[4]["email"]) + res = client.post( + "/admin/delete_widget_design_page", + data=json.dumps({"page_id": 1}), + content_type="application/json") + assert res.status_code == 403 + + +def test_delete_widget_design_page_scope_anonymous(client, users, db_register): + """未ログインの場合はログイン画面へリダイレクトされること(login_requiredが先に働く)。""" + res = client.post( + "/admin/delete_widget_design_page", + data=json.dumps({"page_id": 1}), + content_type="application/json") + assert res.status_code == 302 + + +def test_delete_widget_design_page_scope_id_param_not_found(client, users): + """id_paramで指定したページが存在しない場合は404になること。""" + login_user_via_session(client=client, email=users[3]["email"]) + res = client.post( + "/admin/delete_widget_design_page", + data=json.dumps({"page_id": 999}), + content_type="application/json") + assert res.status_code == 404 + + +# def delete_widget_item(): +# +# WidgetItem's primary key column is widget_id, not id. If pk_attr were left +# at its default ('id'), WidgetItem.query.filter_by(id=...) would raise +# InvalidRequestError (no such column) and turn into a 500 for a Community +# Administrator (System/Repository Administrator would never hit this path +# since they return early). This is the specific regression this decorator +# application must avoid on this endpoint. +def test_delete_widget_item_scope_community_admin_allowed_pk_attr( + client, users, db_register): + """pk_attr='widget_id'が機能し、担当コミュニティ管理者が例外なく200になること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("weko_gridlayout.views.WidgetItemServices.delete_by_id", + return_value={}): + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=IN_SCOPE_COMMUNITY): + res = client.post( + "/admin/delete_widget_item", + data=json.dumps({"data_id": 1}), + content_type="application/json") + assert res.status_code == 200 + + +def test_delete_widget_item_scope_community_admin_denied( + client, users, db_register): + """担当外コミュニティのCommunity Administratorは拒否されること。""" + login_user_via_session(client=client, email=users[3]["email"]) + with patch("invenio_communities.models.Community.get_repositories_by_user", + return_value=OUT_OF_SCOPE_COMMUNITY): + res = client.post( + "/admin/delete_widget_item", + data=json.dumps({"data_id": 1}), + content_type="application/json") + assert res.status_code == 403 + + +def test_delete_widget_item_scope_no_role(client, users, db_register): + """ロールなしログインユーザーは拒否されること。""" + login_user_via_session(client=client, email=users[4]["email"]) + res = client.post( + "/admin/delete_widget_item", + data=json.dumps({"data_id": 1}), + content_type="application/json") + assert res.status_code == 403 + + +def test_delete_widget_item_scope_anonymous(client, users, db_register): + """未ログインの場合はログイン画面へリダイレクトされること(login_requiredが先に働く)。""" + res = client.post( + "/admin/delete_widget_item", + data=json.dumps({"data_id": 1}), + content_type="application/json") + assert res.status_code == 302 + + +def test_delete_widget_item_scope_id_param_not_found(client, users): + """id_paramで指定したウィジェットが存在しない場合は404になること。""" + login_user_via_session(client=client, email=users[3]["email"]) + res = client.post( + "/admin/delete_widget_item", + data=json.dumps({"data_id": 999}), + content_type="application/json") + assert res.status_code == 404 diff --git a/modules/weko-gridlayout/weko_gridlayout/views.py b/modules/weko-gridlayout/weko_gridlayout/views.py index e83d4fbbda..8782433997 100644 --- a/modules/weko-gridlayout/weko_gridlayout/views.py +++ b/modules/weko-gridlayout/weko_gridlayout/views.py @@ -22,9 +22,11 @@ from werkzeug.exceptions import NotFound from invenio_db import db +from weko_admin.permissions import repository_scope_required + from .api import WidgetItems from .config import WEKO_GRIDLAYOUT_ACCESS_COUNTER_TYPE -from .models import WidgetDesignPage +from .models import WidgetDesignPage, WidgetItem from .services import WidgetDataLoaderServices, WidgetDesignPageServices, \ WidgetDesignServices, WidgetItemServices from .utils import WidgetBucket, get_default_language, \ @@ -143,6 +145,7 @@ def load_widget_design_page_setting(page_id: str, current_language=''): @blueprint_api.route('/load_widget_list_design_setting', methods=['POST']) @login_required +@repository_scope_required(repository_id_param='repository_id') def load_widget_list_design_setting(): """Get Widget list, to display on the Widget List panel on UI. @@ -178,6 +181,8 @@ def load_widget_list_design_setting(): @blueprint_api.route('/save_widget_layout_setting', methods=['POST']) @login_required +@repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=WidgetDesignPage) # TODO: Allow this to be used for both or make a different path def save_widget_layout_setting(): """Save Widget design setting into DB. @@ -254,6 +259,8 @@ def load_widget_design_page(): @blueprint_api.route('/save_widget_design_page', methods=['POST']) @login_required +@repository_scope_required(repository_id_param='repository_id', + id_param='page_id', id_model=WidgetDesignPage) def save_widget_design_page(): """Save Widget design page into DB. @@ -275,6 +282,7 @@ def save_widget_design_page(): @blueprint_api.route('/delete_widget_design_page', methods=['POST']) @login_required +@repository_scope_required(id_param='page_id', id_model=WidgetDesignPage) def delete_widget_design_page(): """Delete Widget design page into DB. @@ -304,6 +312,9 @@ def load_widget_type(): @blueprint_api.route('/save_widget_item', methods=['POST']) @login_required +@repository_scope_required(repository_id_param='data.repository', + id_param='data_id', id_model=WidgetItem, + pk_attr='widget_id') def save_widget_item(): """Save Language List.""" if request.headers['Content-Type'] != 'application/json': @@ -315,6 +326,8 @@ def save_widget_item(): @blueprint_api.route('/delete_widget_item', methods=['POST']) @login_required +@repository_scope_required(id_param='data_id', id_model=WidgetItem, + pk_attr='widget_id') def delete_widget_item(): """Delete Language List.""" if request.headers['Content-Type'] != 'application/json': diff --git a/modules/weko-items-ui/tests/test_utils.py b/modules/weko-items-ui/tests/test_utils.py index ec20391119..4a9be3c7ef 100644 --- a/modules/weko-items-ui/tests/test_utils.py +++ b/modules/weko-items-ui/tests/test_utils.py @@ -8718,6 +8718,96 @@ def test__export_item(app, db, users, db_records, db_itemtype): assert _export_item(1,'JSON',True,'./tests/data/',records_data)[1] == {'1': {'weko_creator_id': '1', "weko_shared_ids": []}} +# issue62783: 非公開/他人所有アイテムのexportスキップ(所有権・公開状態チェック) +# .tox/c1/bin/pytest --cov=weko_items_ui tests/test_utils.py::test__export_item_permission_public_anonymous -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp +def test__export_item_permission_public_anonymous(app, db, users, db_records, db_itemtype): + """未ログイン(匿名)ユーザーが公開アイテムをexport -> 成功すること.""" + depid, recid, parent, doi, record, item = db_records[0] + with app.test_request_context(headers=[("Accept-Language", "en")]): + # current_user未認証(匿名)を想定 + with patch("flask_login.utils._get_user", return_value=None), \ + patch("weko_items_ui.utils.get_user_roles", return_value=(False, None)), \ + patch("weko_items_ui.utils.check_created_id", return_value=False), \ + patch("weko_items_ui.utils.check_publish_status", return_value=True): + exported_item, list_item_role = _export_item( + 1, 'JSON', False, './tests/data/' + ) + assert exported_item != {} + assert exported_item['record_id'] == record.id + + +# .tox/c1/bin/pytest --cov=weko_items_ui tests/test_utils.py::test__export_item_permission_private_anonymous_skip -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp +def test__export_item_permission_private_anonymous_skip(app, db, users, db_records, db_itemtype): + """未ログイン(匿名)ユーザーが非公開アイテムをexport -> スキップされること.""" + depid, recid, parent, doi, record, item = db_records[2] + with app.test_request_context(headers=[("Accept-Language", "en")]): + with patch("flask_login.utils._get_user", return_value=None), \ + patch("weko_items_ui.utils.get_user_roles", return_value=(False, None)), \ + patch("weko_items_ui.utils.check_created_id", return_value=False), \ + patch("weko_items_ui.utils.check_publish_status", return_value=False): + assert _export_item(2, 'JSON', False, './tests/data/') == ({}, {}) + + +# .tox/c1/bin/pytest --cov=weko_items_ui tests/test_utils.py::test__export_item_permission_private_owner -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp +def test__export_item_permission_private_owner(app, db, users, db_records, db_itemtype): + """非公開アイテムをその所有者がexport -> 成功すること.""" + depid, recid, parent, doi, record, item = db_records[2] + with app.test_request_context(headers=[("Accept-Language", "en")]): + with patch("flask_login.utils._get_user", return_value=users[0]["obj"]), \ + patch("weko_items_ui.utils.get_user_roles", return_value=(False, [users[0]["id"]])), \ + patch("weko_items_ui.utils.check_created_id", return_value=True), \ + patch("weko_items_ui.utils.check_publish_status", return_value=False): + exported_item, list_item_role = _export_item( + 2, 'JSON', False, './tests/data/' + ) + assert exported_item != {} + assert exported_item['record_id'] == record.id + + +# .tox/c1/bin/pytest --cov=weko_items_ui tests/test_utils.py::test__export_item_permission_private_other_user_skip -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp +def test__export_item_permission_private_other_user_skip(app, db, users, db_records, db_itemtype): + """非公開アイテムを所有者でない第三者(ログイン済)がexport -> スキップされること.""" + depid, recid, parent, doi, record, item = db_records[2] + with app.test_request_context(headers=[("Accept-Language", "en")]): + with patch("flask_login.utils._get_user", return_value=users[7]["obj"]), \ + patch("weko_items_ui.utils.get_user_roles", return_value=(False, [users[7]["id"]])), \ + patch("weko_items_ui.utils.check_created_id", return_value=False), \ + patch("weko_items_ui.utils.check_publish_status", return_value=False): + assert _export_item(2, 'JSON', False, './tests/data/') == ({}, {}) + + +# .tox/c1/bin/pytest --cov=weko_items_ui tests/test_utils.py::test_export_items_skip_non_permitted_records -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp +def test_export_items_skip_non_permitted_records(app, db_itemtype, db_records, users): + """公開/非公開アイテムが混在する一括exportで、公開アイテムのみ結果に含まれること.""" + post_data = { + 'export_file_contents_radio': 'False', + 'export_format_radio': 'JSON', + 'record_ids': '[1,2]', + 'invalid_record_ids': '[]', + } + with app.test_request_context(headers=[("Accept-Language", "en")]): + with patch("flask_login.utils._get_user", return_value=users[1]["obj"]): + def fake_export_item(record_id, *args, **kwargs): + if str(record_id) == '1': + return ( + { + 'record_id': 1, + 'name': 'recid_1', + 'files': [], + 'path': 'recid_1', + 'item_type_id': '1', + 'researchmap_linkage': '', + }, + {}, + ) + # 非公開かつ権限なし -> スキップ扱い + return {}, {} + + with patch("weko_items_ui.utils._export_item", side_effect=fake_export_item): + res = export_items(post_data) + assert res.status_code == 200 + + # def _custom_export_metadata(record_metadata: dict, hide_item: bool = True, # .tox/c1/bin/pytest --cov=weko_items_ui tests/test_utils.py::test__custom_export_metadata -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-items-ui/.tox/c1/tmp def test__custom_export_metadata(app,db_itemtype,users): diff --git a/modules/weko-items-ui/weko_items_ui/utils.py b/modules/weko-items-ui/weko_items_ui/utils.py index 74e7f6fc76..e3ac780fff 100644 --- a/modules/weko-items-ui/weko_items_ui/utils.py +++ b/modules/weko-items-ui/weko_items_ui/utils.py @@ -2625,6 +2625,8 @@ def export_items(post_data): include_contents, record_path, ) + if not exported_item: + continue # 権限なしレコードはスキップ result['items'].append(exported_item) item_type_id = exported_item.get('item_type_id') @@ -2858,6 +2860,14 @@ def del_hide_sub_metadata(keys, metadata): record = WekoRecord.get_record_by_pid(record_id) list_item_role = {} if record: + roles = get_user_roles() + is_allowed = ( + roles[0] + or check_created_id(record) + or check_publish_status(record) + ) + if not is_allowed: + return {}, {} # 権限なしレコードはスキップ扱い exported_item['record_id'] = record.id exported_item['name'] = 'recid_{}'.format(record_id) exported_item['files'] = [] diff --git a/modules/weko-records-ui/tests/test_views.py b/modules/weko-records-ui/tests/test_views.py index af8ddd607a..c428874d7d 100644 --- a/modules/weko-records-ui/tests/test_views.py +++ b/modules/weko-records-ui/tests/test_views.py @@ -1640,27 +1640,53 @@ def test_preview_able(app): assert ret == False # def get_uri(): +# no.499: get_uri には record_edit_permission_required(param='pid_value') を +# 追加した。JSON body のキー名は pid_value(pid ではない)なので、記録の所有者 +# (records フィクスチャの owner=1 = users[7] "user@test.org")でのみ成功し、 +# 無関係な第三者(users[4] "generaluser@test.org")は拒否されることを確認する。 # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_get_uri -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp -def test_get_uri(app,client,db_sessionlifetime,records): +def test_get_uri(app,client,db_sessionlifetime,records,users): # 404を発生させるとwebassets.exceptions.FilterErrorが発生する対策 app.register_error_handler(404, None) url = url_for("weko_records_ui.get_uri", _external=True) + + # 匿名ユーザー -> 401(login_required) res = client.post(url,data=json.dumps({"uri":"https://localhost/record/1/files/001.jpg","pid_value":"1","accessrole":"1"}), content_type='application/json') - assert res.status_code == 200 - assert json.loads(res.data)=={'status': True} + assert res.status_code == 401 - res = client.post(url,data=json.dumps({"uri":"https://localhost/001.jpg","pid_value":"1","accessrole":"1"}), content_type='application/json') - assert res.status_code == 200 - assert json.loads(res.data)=={'status': True} + # レコード編集権限のないユーザー(第三者) -> 403 + with patch("flask_login.utils._get_user", return_value=users[4]["obj"]): + res = client.post(url,data=json.dumps({"uri":"https://localhost/record/1/files/001.jpg","pid_value":"1","accessrole":"1"}), content_type='application/json') + assert res.status_code == 403 - # Invalid request data - res = client.post("/get_uri") - assert res.status_code == 400 + # レコード編集権限のあるユーザー(所有者) -> 成功 + with patch("flask_login.utils._get_user", return_value=users[7]["obj"]): + res = client.post(url,data=json.dumps({"uri":"https://localhost/record/1/files/001.jpg","pid_value":"1","accessrole":"1"}), content_type='application/json') + assert res.status_code == 200 + assert json.loads(res.data)=={'status': True} - # Invalid pid_value - res = client.post(url,data=json.dumps({"uri":"https://localhost/001.jpg","pid_value":"test","accessrole":"1"}), content_type='application/json', follow_redirects=False) - assert res.status_code == 404 + res = client.post(url,data=json.dumps({"uri":"https://localhost/001.jpg","pid_value":"1","accessrole":"1"}), content_type='application/json') + assert res.status_code == 200 + assert json.loads(res.data)=={'status': True} + + # Invalid request data + res = client.post("/get_uri") + assert res.status_code == 400 + + # Invalid pid_value + # record_edit_permission_required が先に check_created_id_by_recid("test") + # を評価し、存在しない recid なので permitted=False として 403 を返す + # (view 本体の NoResultFound/PIDDoesNotExistError -> 404 処理まで到達しない) + res = client.post(url,data=json.dumps({"uri":"https://localhost/001.jpg","pid_value":"test","accessrole":"1"}), content_type='application/json', follow_redirects=False) + assert res.status_code == 403 + + # pid_value ではなく別のキー名(pid)で送るとパラメータが見つからず 400 + # (record_edit_permission_required(param='pid_value') が正しいキー名を + # 見ていることの確認) + with patch("flask_login.utils._get_user", return_value=users[7]["obj"]): + res = client.post(url,data=json.dumps({"uri":"https://localhost/record/1/files/001.jpg","pid":"1","accessrole":"1"}), content_type='application/json') + assert res.status_code == 400 # .tox/c1/bin/pytest --cov=weko_records_ui tests/test_views.py::test_default_view_method_fix35133 -v -vv -s --cov-branch --cov-report=term --basetemp=/code/modules/weko-records-ui/.tox/c1/tmp diff --git a/modules/weko-records-ui/weko_records_ui/rest.py b/modules/weko-records-ui/weko_records_ui/rest.py index 7e3fb0cd7f..bcf506d688 100644 --- a/modules/weko-records-ui/weko_records_ui/rest.py +++ b/modules/weko-records-ui/weko_records_ui/rest.py @@ -650,8 +650,8 @@ def __init__(self, serializers, ctx, *args, **kwargs): for key, value in ctx.items(): setattr(self, key, value) - # @pass_record - # @need_record_permission('read_permission_factory') + @require_api_auth(allow_anonymous=True) + @require_oauth_scopes(item_read_scope.id) def get(self, pid_value, **kwargs): """Render citation for record according to style and language.""" from weko_records.serializers import citeproc_v1 @@ -660,6 +660,8 @@ def get(self, pid_value, **kwargs): try: pid = PersistentIdentifier.get('depid', pid_value) record = WekoRecord.get_record(pid.object_uuid) + if not page_permission_factory(record).can(): + raise PermissionError() result = citeproc_v1.serialize(pid, record, style=style, locale=locale) result = escape_str(result) @@ -667,7 +669,7 @@ def get(self, pid_value, **kwargs): except Exception: current_app.logger.exception( 'Citation formatting for record {0} failed.'.format( - str(record.id))) + str(pid_value))) # record.id ではなく pid_value を参照(UnboundLocalError修正) return make_response(jsonify("Not found"), 404) diff --git a/modules/weko-records-ui/weko_records_ui/views.py b/modules/weko-records-ui/weko_records_ui/views.py index a5eb04f6c3..e118b35043 100644 --- a/modules/weko-records-ui/weko_records_ui/views.py +++ b/modules/weko-records-ui/weko_records_ui/views.py @@ -1459,6 +1459,8 @@ def preview_able(file_json): return True @blueprint.route("/get_uri", methods=['POST']) +@login_required +@record_edit_permission_required(param='pid_value') def get_uri(): """_summary_ --- diff --git a/modules/weko-theme/weko_theme/templates/weko_theme/macros/tabs_selector.html b/modules/weko-theme/weko_theme/templates/weko_theme/macros/tabs_selector.html index 93b2549dd5..071a82f245 100644 --- a/modules/weko-theme/weko_theme/templates/weko_theme/macros/tabs_selector.html +++ b/modules/weko-theme/weko_theme/templates/weko_theme/macros/tabs_selector.html @@ -21,12 +21,12 @@ {% macro tabs_selector(tab_value='top',community_id='') %} {%- if community_id %}