From dd08c5d640a3c5c7384cca864af622d2ce5cdac8 Mon Sep 17 00:00:00 2001 From: huanphan-tma Date: Thu, 7 Mar 2024 15:26:17 +0700 Subject: [PATCH 1/2] =?UTF-8?q?refs=202.2.1.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E6=A8=A9=E9=99=90=E8=A8=AD=E5=AE=9A=E6=A9=9F=E8=83=BD:=20Add?= =?UTF-8?q?=20source=20code=20and=20UT=20test=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/base/schemas/config-schema.json | 31 ++ ...service-access-control-setting-schema.json | 56 ++++ admin/base/settings/defaults.py | 2 +- admin/base/urls.py | 1 + .../__init__.py | 0 .../settings/config_data.json | 129 ++++++++ admin/service_access_control_setting/urls.py | 7 + admin/service_access_control_setting/views.py | 267 +++++++++++++++ admin/static/css/institutions.css | 10 +- .../service-access-control-setting.js | 62 ++++ admin/templates/base.html | 7 + .../service_access_control_setting/list.html | 123 +++++++ admin/translations/django.pot | 32 ++ admin/translations/en/LC_MESSAGES/django.po | 32 ++ admin/translations/ja/LC_MESSAGES/django.po | 32 ++ admin/webpack.admin.config.js | 1 + .../__init__.py | 0 .../test_views.py | 305 ++++++++++++++++++ api/base/middleware.py | 17 +- api/base/settings/defaults.py | 86 +++++ api/nodes/views.py | 9 +- api/users/urls.py | 1 + api/users/views.py | 19 ++ api_tests/base/test_middleware.py | 45 ++- api_tests/nodes/views/test_node_list.py | 12 + api_tests/users/views/test_user_settings.py | 23 ++ framework/function_control/__init__.py | 0 framework/function_control/handlers.py | 110 +++++++ osf/migrations/0233_auto_20240227_0348.py | 71 ++++ osf/models/__init__.py | 2 + osf/models/function.py | 13 + osf/models/service_access_control_setting.py | 19 ++ osf/models/user.py | 78 +++++ osf_tests/factories.py | 19 ++ osf_tests/test_app.py | 1 + osf_tests/test_function.py | 30 ++ .../test_service_access_control_setting.py | 80 +++++ osf_tests/test_user.py | 79 +++++ .../framework_tests/test_function_control.py | 183 +++++++++++ website/app.py | 4 + website/routes.py | 10 + website/static/js/addProjectPlugin.js | 19 +- website/static/js/citationList.js | 15 +- website/static/js/contribAdder.js | 14 + website/static/js/contribManager.js | 14 +- website/static/js/contribRemover.js | 10 +- website/static/js/licensePicker.js | 10 +- website/static/js/myProjects.js | 8 +- website/static/js/nodeControl.js | 35 +- website/static/js/nodesDelete.js | 14 +- website/static/js/nodesPrivacy.js | 8 +- website/static/js/osfHelpers.js | 25 ++ .../static/js/pages/project-dashboard-page.js | 18 +- website/static/js/pointers.js | 10 +- website/static/js/privateLinkManager.js | 10 +- website/static/js/privateLinkTable.js | 12 +- website/static/js/project.js | 34 +- website/static/js/projectSettings.js | 21 +- website/templates/my_projects.mako | 1 + .../en/LC_MESSAGES/js_messages.po | 18 ++ .../ja/LC_MESSAGES/js_messages.po | 18 ++ website/translations/js_messages.pot | 17 + website/views.py | 1 + 63 files changed, 2302 insertions(+), 38 deletions(-) create mode 100644 admin/base/schemas/config-schema.json create mode 100644 admin/base/schemas/service-access-control-setting-schema.json create mode 100644 admin/service_access_control_setting/__init__.py create mode 100644 admin/service_access_control_setting/settings/config_data.json create mode 100644 admin/service_access_control_setting/urls.py create mode 100644 admin/service_access_control_setting/views.py create mode 100644 admin/static/js/service_access_control_setting/service-access-control-setting.js create mode 100644 admin/templates/service_access_control_setting/list.html create mode 100644 admin_tests/service_access_control_setting/__init__.py create mode 100644 admin_tests/service_access_control_setting/test_views.py create mode 100644 framework/function_control/__init__.py create mode 100644 framework/function_control/handlers.py create mode 100644 osf/migrations/0233_auto_20240227_0348.py create mode 100644 osf/models/function.py create mode 100644 osf/models/service_access_control_setting.py create mode 100644 osf_tests/test_function.py create mode 100644 osf_tests/test_service_access_control_setting.py create mode 100644 tests/framework_tests/test_function_control.py diff --git a/admin/base/schemas/config-schema.json b/admin/base/schemas/config-schema.json new file mode 100644 index 00000000000..a5331236aa9 --- /dev/null +++ b/admin/base/schemas/config-schema.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "patternProperties": { + "^[^\\n]+$": { + "type": "object", + "properties": { + "function_name": { + "type": "string" + }, + "api_group": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "api": { + "type": "string" + }, + "method": { + "type": "string" + } + }, + "required": ["api", "method"] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/admin/base/schemas/service-access-control-setting-schema.json b/admin/base/schemas/service-access-control-setting-schema.json new file mode 100644 index 00000000000..80cfd43bc1b --- /dev/null +++ b/admin/base/schemas/service-access-control-setting-schema.json @@ -0,0 +1,56 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "institution_id": { + "type": "string", + "maxLength": 255 + }, + "domain": { + "type": "string", + "maxLength": 255, + "pattern": "^(default)$|^(?!\\d+\\.)+(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9]{0,62}[a-z0-9]$" + }, + "is_ial2_or_aal2": { + "type": "boolean" + }, + "user_domain": { + "type": "string", + "maxLength": 255, + "pattern": "^(default)$|^(@|\\.|)(?!\\d+\\.)+(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9]{0,62}[a-z0-9]$" + }, + "project_limit_number": { + "type": "integer", + "minimum": 1 + }, + "is_whitelist": { + "type": "boolean" + }, + "function_codes": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + }, + "required": [ + "institution_id", + "domain", + "is_ial2_or_aal2", + "user_domain", + "is_whitelist", + "function_codes" + ] + }, + "additionalProperties": false + } + }, + "required": ["data"], + "additionalProperties": false +} diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index 934c561b41d..79fbb36e0e2 100644 --- a/admin/base/settings/defaults.py +++ b/admin/base/settings/defaults.py @@ -259,7 +259,7 @@ WSGI_APPLICATION = 'admin.base.wsgi.application' ADMIN_BASE = '' STATIC_URL = '/static/' -LOGIN_URL = 'account/login/' +LOGIN_URL = '/account/login/' LOGIN_REDIRECT_URL = ADMIN_BASE STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), 'static_root') diff --git a/admin/base/urls.py b/admin/base/urls.py index a32acef0935..426e7d5711f 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -49,6 +49,7 @@ url(r'^institutional_storage_quota_control/', include('admin.institutional_storage_quota_control.urls', namespace='institutional_storage_quota_control')), url(r'^metadata/', include('admin.rdm_metadata.urls', namespace='metadata')), + url(r'^service_access_control_setting/', include('admin.service_access_control_setting.urls', namespace='service_access_control_setting')), ]), ), ] diff --git a/admin/service_access_control_setting/__init__.py b/admin/service_access_control_setting/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin/service_access_control_setting/settings/config_data.json b/admin/service_access_control_setting/settings/config_data.json new file mode 100644 index 00000000000..0de2b07ac94 --- /dev/null +++ b/admin/service_access_control_setting/settings/config_data.json @@ -0,0 +1,129 @@ +{ + "function_001":{ + "function_name":"プロジェクト作成", + "api_group":[ + { + "api":"^\/v2\/nodes\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/?$", + "method":"PUT" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/?$", + "method":"DELETE" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/edit\/?$", + "method":"POST" + }, + { + "api":"^\/v2\/nodes\/[a-z0-9A-Z]+\/children\/?$", + "method":"POST" + }, + { + "api":"^\/v2\/nodes\/[a-z0-9A-Z]+\/forks\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/new\/[a-z0-9A-Z]+\/?$", + "method":"POST" + }, + { + "api":"^\/v2\/nodes\/[a-z0-9A-Z]+\/node_links\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/pointer\/?$", + "method":"DELETE" + }, + { + "api":"^\/v2\/nodes\/[a-z0-9A-Z]+\/?$", + "method":"PATCH" + }, + { + "api":"^\/api\/v1\/pointer\/?$", + "method":"POST" + }, + { + "api":"^\/v2\/collections\/[a-z0-9A-Z]+\/relationships\/linked_nodes\/?$", + "method":"DELETE" + }, + { + "api":"^\/v2\/nodes\/?$", + "method":"PATCH" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/invite_contributor\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/contributors\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/contributor\/remove\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/contributors\/manage\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/private_link\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/private_link\/?$", + "method":"DELETE" + }, + { + "api":"^\/v2\/nodes\/?$", + "method":"DELETE" + } + ] + }, + "function_002":{ + "function_name":"アドオン", + "api_group":[ + { + "api":"^\/[a-z0-9A-Z]+\/addons\/?$", + "method":"GET" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/settings\/addons\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/settings\/[a-z0-9A-Z]+\/accounts\/?$", + "method":"POST" + }, + { + "api":"^\/oauth\/connect\/[a-z0-9A-Z]+\/?$", + "method":"GET" + }, + { + "api":"^\/api\/v1\/project\/[a-z0-9A-Z]+\/binderhub\/settings\/?$", + "method":"PUT" + } + ] + }, + "function_003":{ + "function_name":"検索", + "api_group":[ + { + "api":"^\/search\/?", + "method":"GET" + }, + { + "api":"^\/api\/v1\/search\/?$", + "method":"POST" + }, + { + "api":"^\/api\/v1\/search\/[a-z0-9A-Z]+\/?$", + "method":"POST" + } + ] + } +} diff --git a/admin/service_access_control_setting/urls.py b/admin/service_access_control_setting/urls.py new file mode 100644 index 00000000000..3c20f7509d6 --- /dev/null +++ b/admin/service_access_control_setting/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url(r'^$', views.ServiceAccessControlSettingView.as_view(), name='list'), + url(r'^setting/$', views.ServiceAccessControlSettingCreateView.as_view(), name='create_setting'), +] diff --git a/admin/service_access_control_setting/views.py b/admin/service_access_control_setting/views.py new file mode 100644 index 00000000000..aa2504428af --- /dev/null +++ b/admin/service_access_control_setting/views.py @@ -0,0 +1,267 @@ +import logging +import os +import json +from itertools import groupby + +import jsonschema + +from collections import OrderedDict + +from django.contrib.postgres.aggregates import ArrayAgg +from django.db import transaction +from django.db.models import Subquery, OuterRef +from django.http import JsonResponse +from django.template.defaultfilters import register +from django.contrib.auth.mixins import UserPassesTestMixin +from django.utils import timezone +from django.views import View +from django.views.generic import ListView +from rest_framework import status as http_status + +from osf.exceptions import ValidationError +from osf.models import Function, Institution +from osf.models.service_access_control_setting import ServiceAccessControlSetting +from admin.base.schemas.utils import from_json +from admin.base.settings import BASE_DIR +from admin.rdm.utils import RdmPermissionMixin + +logger = logging.getLogger(__name__) + +CONFIG_PATH = 'service_access_control_setting/settings/config_data.json' +CONFIG_SCHEMA_FILE_NAME = 'config-schema.json' +SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME = 'service-access-control-setting-schema.json' +JSON_FILE_INVALID_RESPONSE = { + 'message': 'JSON file is invalid.' +} +CONFIG_DATA_INVALID_RESPONSE = { + 'message': 'Config data is invalid.' +} + + +@register.simple_tag +def get_rowspan(row_info_dict, filter_key): + """ A Django simple tag that return rowspan value by filter_key """ + # Get value from dict based on filter key. If not found then return 1 + return row_info_dict.get(filter_key, 1) + + +class ServiceAccessControlSettingView(UserPassesTestMixin, RdmPermissionMixin, ListView): + """ Allow an administrator to view service access control setting """ + paginate_by = 25 + template_name = 'service_access_control_setting/list.html' + raise_exception = True + model = ServiceAccessControlSetting + + def test_func(self): + """check user permissions""" + if not self.is_authenticated: + self.raise_exception = False + return False + return self.is_super_admin or self.is_admin + + def get_queryset(self): + # Create sub queryset to get institution name by institution guid + institution_subquery = Institution.objects.filter(_id=OuterRef('institution_id')).values('name') + # Create queryset + queryset = ServiceAccessControlSetting.objects.filter( + functions__isnull=False, is_deleted=False + ).annotate( + institution_name=Subquery(institution_subquery), function_codes=ArrayAgg('functions__function_code') + ).order_by('institution_name', 'domain', 'is_ial2_or_aal2', 'user_domain') + if self.is_super_admin: + # Get settings for all institutions + return queryset + elif self.is_admin: + # Get settings for administrator's institution + user = self.request.user + institution = user.affiliated_institutions.filter(is_deleted=False).first() + return queryset.filter(institution_id=institution.guid) + # Otherwise, return none queryset + return ServiceAccessControlSetting.objects.none() + + def get_context_data(self, **kwargs): + try: + # Load JSON config data + with open(os.path.join(BASE_DIR, CONFIG_PATH), encoding='utf-8') as fp: + config_data = json.load(fp, object_pairs_hook=OrderedDict) + # Load config data schema json file + function_config_schema = from_json(CONFIG_SCHEMA_FILE_NAME) + # Validate config data with the JSON schema + jsonschema.validate(config_data, function_config_schema) + except Exception: + # Return no data + return { + 'column_data': {}, + 'row_data': [], + } + + # Convert config data into dict of {"function_code": "function_name value"} + function_data = {kv[0]: kv[1].get('function_name', '') for i, kv in enumerate(config_data.items())} + + # Paginate the queryset + query_set = kwargs.pop('object_list', self.object_list) + page_size = self.get_paginate_by(query_set) + paginator, page, query_set, is_paginated = self.paginate_queryset( + query_set, page_size) + + # Get rowspan info for rendering table + institution_id_rowspan_info = {} + domain_rowspan_info = {} + ial2_aal2_rowspan_info = {} + if query_set.exists(): + # institution_id_rowspan_info example value: {'gakunin': 3} + for k, v in groupby(query_set, key=lambda x: (x.institution_id,)): + institution_id_rowspan_info['__'.join(map(str, k))] = len(list(v)) + # domain_rowspan_info example value: {'gakunin__default': 2, 'gakunin__test.com': 1} + for k, v in groupby(query_set, key=lambda x: (x.institution_id, x.domain,)): + domain_rowspan_info['__'.join(map(str, k))] = len(list(v)) + # ial2_aal2_rowspan_info example value: {'gakunin__default__True': 1, 'gakunin__default__False': 1} + for k, v in groupby(query_set, key=lambda x: (x.institution_id, x.domain, x.is_ial2_or_aal2,)): + ial2_aal2_rowspan_info['__'.join(map(str, k))] = len(list(v)) + + return { + 'column_data': function_data, + 'row_data': query_set, + 'page': page, + 'institution_id_rowspan_info': institution_id_rowspan_info, + 'domain_rowspan_info': domain_rowspan_info, + 'ial2_aal2_rowspan_info': ial2_aal2_rowspan_info, + } + + +class ServiceAccessControlSettingCreateView(UserPassesTestMixin, RdmPermissionMixin, View): + """ Allow an integrated administrator to update service access control setting """ + raise_exception = True + + def test_func(self): + """check user permissions""" + if not self.is_authenticated: + self.raise_exception = False + return False + return self.is_super_admin + + def post(self, request): + """Handle upload setting file request""" + # Default response is HTTP 200 OK + response_body = {} + status_code = http_status.HTTP_200_OK + + # If there is error, raise and catch exception, then return response later + try: + try: + # Load setting data from uploaded JSON file + file = self.parse_file(request.FILES['file']) + setting_json = json.loads(file) + except ValueError as e: + # Fail to load setting data, return HTTP 500 + logger.error(f'Fail to load setting data with error {e}') + raise e + + try: + # Load setting JSON schema file + setting_schema = from_json(SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME) + # Validate setting data with the JSON schema + jsonschema.validate(setting_json, setting_schema) + except (jsonschema.ValidationError, jsonschema.SchemaError) as e: + logger.error(f'JSON file is invalid: {e}') + response_body = JSON_FILE_INVALID_RESPONSE + status_code = http_status.HTTP_400_BAD_REQUEST + raise e + + try: + # Load config data file + with open(os.path.join(BASE_DIR, CONFIG_PATH), encoding='utf-8') as fp: + function_config_json = json.load(fp) + # Load config data schema json file + function_config_schema = from_json(CONFIG_SCHEMA_FILE_NAME) + # Validate config data with the JSON schema + jsonschema.validate(function_config_json, function_config_schema) + except Exception as e: + logger.error(f'Config data is invalid: {e}') + response_body = CONFIG_DATA_INVALID_RESPONSE + status_code = http_status.HTTP_400_BAD_REQUEST + raise e + + function_config_codes = {item[0] for item in function_config_json.items()} + service_access_control_settings = [] + function_settings = [] + validation_list = [] + for setting_data_item in setting_json.get('data', []): + institution_id = setting_data_item.get('institution_id') + domain = setting_data_item.get('domain') + is_ial2_or_aal2 = setting_data_item.get('is_ial2_or_aal2') + user_domain = setting_data_item.get('user_domain') + function_codes = setting_data_item.get('function_codes', []) + is_whitelist = setting_data_item.get('is_whitelist') + project_limit_number = setting_data_item.get('project_limit_number') + + if not Institution.objects.filter(_id=institution_id).exists(): + # If institution guid does not exist, return HTTP 400 + logger.error('JSON file is invalid: institution_id not found.') + response_body = JSON_FILE_INVALID_RESPONSE + status_code = http_status.HTTP_400_BAD_REQUEST + raise ValidationError('JSON file is invalid: institution_id not found.') + + function_codes_set = set(function_codes) + if not function_codes_set.issubset(function_config_codes): + # If there are some function codes that are not in config file, return HTTP 400 + logger.error('JSON file is invalid: some function codes are not in config file.') + response_body = JSON_FILE_INVALID_RESPONSE + status_code = http_status.HTTP_400_BAD_REQUEST + raise ValidationError('JSON file is invalid: some function codes are not in config file.') + + # Prepare data for later duplication validation + validation_list.append((institution_id, domain, is_ial2_or_aal2, user_domain,)) + + # Prepare data for bulk-insert + new_setting = ServiceAccessControlSetting( + institution_id=institution_id, + domain=domain, + is_ial2_or_aal2=is_ial2_or_aal2, + user_domain=user_domain, + is_whitelist=is_whitelist, + project_limit_number=project_limit_number + ) + service_access_control_settings.append(new_setting) + function_settings.extend([ + Function(function_code=item, service_access_control_setting=new_setting) for item in function_codes_set] + ) + + if len(validation_list) != len(set(validation_list)): + # If settings has duplicate information, return HTTP 400 + logger.error('JSON file is invalid: upload settings has duplicate information') + response_body = JSON_FILE_INVALID_RESPONSE + status_code = http_status.HTTP_400_BAD_REQUEST + raise ValidationError('JSON file is invalid: upload settings has duplicate information') + + try: + # Begin transaction + with transaction.atomic(): + # Set is_deleted to True for current osf_service_access_control_setting and osf_function records + ServiceAccessControlSetting.objects.filter(is_deleted=False).update(is_deleted=True, modified=timezone.now()) + Function.objects.filter(is_deleted=False).update(is_deleted=True, modified=timezone.now()) + # Bulk insert osf_service_access_control_setting + ServiceAccessControlSetting.objects.bulk_create(service_access_control_settings) + # Bulk insert osf_function + for each in function_settings: + each.service_access_control_setting_id = each.service_access_control_setting.id + Function.objects.bulk_create(function_settings) + except Exception as e: + logger.error(f'Exception raised in the create setting transaction: {e}') + # Raise HTTP 500 + raise e + except Exception as e: + if status_code != http_status.HTTP_400_BAD_REQUEST: + # If request does not plan to return HTTP 400 then return HTTP 500 by raising the exception + raise e + + return JsonResponse(response_body, status=status_code) + + def parse_file(self, f): + """Parse file data""" + parsed_file = '' + for chunk in f.chunks(): + if isinstance(chunk, bytes): + chunk = chunk.decode() + parsed_file += chunk + return parsed_file diff --git a/admin/static/css/institutions.css b/admin/static/css/institutions.css index 852be819877..0b8788cda24 100644 --- a/admin/static/css/institutions.css +++ b/admin/static/css/institutions.css @@ -121,4 +121,12 @@ input.apple-switch.checked:after { display:inline-block; flex:1; word-break: break-all; -} \ No newline at end of file +} + +.fixed-table-cell { + min-width: 100px; +} + +.table-header-cell { + background-color: #f9f9f9; +} diff --git a/admin/static/js/service_access_control_setting/service-access-control-setting.js b/admin/static/js/service_access_control_setting/service-access-control-setting.js new file mode 100644 index 00000000000..e6bbdb7bc26 --- /dev/null +++ b/admin/static/js/service_access_control_setting/service-access-control-setting.js @@ -0,0 +1,62 @@ +'use-strict'; + +var $ = require('jquery'); +var $osf = require('js/osfHelpers'); +var _ = require('js/rdmGettext')._; + +var csrftoken = $('[name=csrfmiddlewaretoken]').val(); + +function csrfSafeMethod(method) { + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); +} + +$.ajaxSetup({ + crossDomain: false, + beforeSend: function (xhr, settings) { + if (!csrfSafeMethod(settings.type)) { + xhr.setRequestHeader('X-CSRFToken', csrftoken); + } + } +}); + +$('#upload-button').click(function() { + // Trigger input[type='file'] click event + $('#file-upload').click(); +}); + +$('#file-upload').change(function() { + var files = $('#file-upload').prop('files'); + if (files && files.length > 0) { + var uploadFile = files[0]; + var jsonExtensionRegex = new RegExp('\\.json$'); + if (!uploadFile.name.match(jsonExtensionRegex)) { + $osf.growl('Error', _('Not a JSON file.'), 'danger', 5000); + return; + } + var formData = new FormData(); + formData.append('file', uploadFile); + $.ajax({ + url: 'setting/', + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(json) { + // Reload the page + window.location.reload(); + }, + error: function(jqXHR) { + // Reset input[type='file'] value + $('#file-upload').val(''); + var data = jqXHR.responseJSON; + if (data && data['message']) { + // If response has message, show that message + $osf.growl('Error', _(data['message']), 'danger', 5000); + } else { + // Otherwise, show default error message 'A server error occurred. Please contact the administrator.' + $osf.growl('Error', 'A server error occurred. Please contact the administrator.', 'danger', 5000); + } + } + }); + } +}); diff --git a/admin/templates/base.html b/admin/templates/base.html index dbc239ab3b5..ccec49f6133 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -180,6 +180,13 @@ {% endif %} + {% if user.is_superuser or user.is_staff %} +
  • + + {% trans "Service Access Control" %} + +
  • + {% endif %} {% if user.is_superuser or user.is_staff %}
  • diff --git a/admin/templates/service_access_control_setting/list.html b/admin/templates/service_access_control_setting/list.html new file mode 100644 index 00000000000..e1eaf8b9ab1 --- /dev/null +++ b/admin/templates/service_access_control_setting/list.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% load render_bundle from webpack_loader %} + +{% block top_includes %} + +{% endblock %} + +{% block title %} + {% trans "Service Access Control Setting Management" %} +{% endblock title %} + +{% block content %} +

    {% trans "Service Access Control Setting Management" %}

    + {% if user.is_superuser %} + +
    +
    + {% csrf_token %} + + +
    +
    + {% endif %} + + + {% include "util/pagination.html" with items=page status=status %} + + {% if row_data|length > 0 and column_data|length > 0 %} + +
    + + + + + + + {% for function_code, function_name in column_data.items %} + + {% endfor %} + + + + + + {% for item in row_data %} + + + + {% ifchanged item.institution_id %} + + {% endifchanged %} + + {% ifchanged item.institution_id item.domain %} + + {% endifchanged %} + + {% ifchanged item.institution_id item.domain item.is_ial2_or_aal2 %} + {% with is_ial2_or_aal2_key=item.is_ial2_or_aal2|yesno:'True,False' %} + + {% endwith %} + {% endifchanged %} + + + + + + {% for function_code, function_name in column_data.items %} + + {% endfor %} + + {% endfor %} + +
    {% trans "Institution" %} + {% trans "Project Limit Number" %} + {{ function_name }}
    + {{ item.institution_name }} + + {% if item.domain == 'default' %} + {% trans "Default" %} + {% else %} + {{ item.domain }} + {% endif %} + + {% if item.is_ial2_or_aal2 %} + {% trans "IAL2/AAL2 User" %} + {% else %} + {% trans "Non IAL2/AAL2 User" %} + {% endif %} + + {% if item.user_domain == 'default' %} + {% trans "Default" %} + {% else %} + {{ item.user_domain }} + {% endif %} + + {% if item.project_limit_number is not None %} + {{ item.project_limit_number }} + {% else %} + {% trans "No limit" %} + {% endif %} + + {% if item.is_whitelist and function_code in item.function_codes %} + {% trans "O" %} + {% elif not item.is_whitelist and function_code not in item.function_codes %} + {% trans "O" %} + {% else %} + {% trans "X" %} + {% endif %} +
    +
    + {% else %} +

    {% trans "No results found" %}

    + {% endif %} + +{% endblock content %} + +{% block bottom_js %} + {% render_bundle 'service-access-control-setting' %} +{% endblock %} diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 34f7636d108..3671a046141 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -350,6 +350,10 @@ msgstr "" msgid "RDM Users" msgstr "" +#: admin/templates/base.html:186 +msgid "Service Access Control" +msgstr "" + #: admin/templates/base.html:163 #: admin/templates/user_emails/user_emails.html:112 msgid "User Emails" @@ -2960,6 +2964,34 @@ msgstr "" msgid "Select Timestamp Function" msgstr "" +#: admin/templates/service_access_control_setting/list.html +msgid "Service Access Control Setting Management" +msgstr "" + +msgid "Create setting" +msgstr "" + +msgid "IAL2/AAL2 User" +msgstr "" + +msgid "Non IAL2/AAL2 User" +msgstr "" + +msgid "Default" +msgstr "" + +msgid "Project Limit Number" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "O" +msgstr "" + +msgid "X" +msgstr "" + #: admin/templates/spam/detail.html:7 msgid "Comment" msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index 48d0b6fa028..d23139e66e3 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -357,6 +357,10 @@ msgstr "" msgid "RDM Users" msgstr "" +#: admin/templates/base.html:186 +msgid "Service Access Control" +msgstr "" + #: admin/templates/base.html:163 #: admin/templates/user_emails/user_emails.html:112 msgid "User Emails" @@ -2982,6 +2986,34 @@ msgstr "" msgid "Select Timestamp Function" msgstr "" +#: admin/templates/service_access_control_setting/list.html +msgid "Service Access Control Setting Management" +msgstr "" + +msgid "Create setting" +msgstr "" + +msgid "IAL2/AAL2 User" +msgstr "" + +msgid "Non IAL2/AAL2 User" +msgstr "" + +msgid "Default" +msgstr "" + +msgid "Project Limit Number" +msgstr "" + +msgid "No limit" +msgstr "" + +msgid "O" +msgstr "" + +msgid "X" +msgstr "" + #: admin/templates/spam/detail.html:7 msgid "Comment" msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index 5947f88649e..b5a4e44245f 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -357,6 +357,10 @@ msgstr "削除のリクエスト" msgid "RDM Users" msgstr "ユーザ管理" +#: admin/templates/base.html:186 +msgid "Service Access Control" +msgstr "サービスアクセス制御管理" + #: admin/templates/base.html:163 #: admin/templates/user_emails/user_emails.html:112 msgid "User Emails" @@ -3016,6 +3020,34 @@ msgstr "タイムスタンプの検証" msgid "Select Timestamp Function" msgstr "タイムスタンプ機能を選択" +#: admin/templates/service_access_control_setting/list.html +msgid "Service Access Control Setting Management" +msgstr "サービスアクセス制御管理" + +msgid "Create setting" +msgstr "設定作成" + +msgid "IAL2/AAL2 User" +msgstr "IAL2/AAL2ユーザ" + +msgid "Non IAL2/AAL2 User" +msgstr "IAL2/AAL2ユーザ以外" + +msgid "Default" +msgstr "デフォルト" + +msgid "Project Limit Number" +msgstr "プロジェクト作成可能数" + +msgid "No limit" +msgstr "制限無し" + +msgid "O" +msgstr "〇" + +msgid "X" +msgstr "✕" + #: admin/templates/spam/detail.html:7 msgid "Comment" msgstr "コメント" diff --git a/admin/webpack.admin.config.js b/admin/webpack.admin.config.js index 9f3248232f0..593fa25a6af 100644 --- a/admin/webpack.admin.config.js +++ b/admin/webpack.admin.config.js @@ -55,6 +55,7 @@ var config = Object.assign({}, common, { 'rdm-keymanagement-page': staticAdminPath('js/rdm_keymanagement/rdm-keymanagement-page.js'), 'rdm-institutional-storage-page': staticAdminPath('js/rdm_custom_storage_location/rdm-institutional-storage-page.js'), 'rdm-metadata-page': staticAdminPath('js/rdm_metadata/rdm-metadata-page.js'), + 'service-access-control-setting': staticAdminPath('js/service_access_control_setting/service-access-control-setting.js'), }, plugins: plugins, devtool: 'source-map', diff --git a/admin_tests/service_access_control_setting/__init__.py b/admin_tests/service_access_control_setting/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin_tests/service_access_control_setting/test_views.py b/admin_tests/service_access_control_setting/test_views.py new file mode 100644 index 00000000000..f543de7f528 --- /dev/null +++ b/admin_tests/service_access_control_setting/test_views.py @@ -0,0 +1,305 @@ +import json +import mock + +from django.contrib.auth.models import AnonymousUser +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import IntegrityError +from django.test import RequestFactory +from django.urls import reverse +from nose import tools as nt +from rest_framework import status as http_status + +from admin.service_access_control_setting.views import get_rowspan, ServiceAccessControlSettingView, ServiceAccessControlSettingCreateView +from admin_tests.utilities import setup_user_view +from framework.auth import Auth +from osf.models import ServiceAccessControlSetting +from osf_tests.factories import AuthUserFactory, ServiceAccessControlSettingFactory, FunctionFactory, InstitutionFactory +from tests.base import AdminTestCase + + +class TestServiceAccessControlSettingSimpleTag: + def test_get_rowspan(self): + # Key exist in dict + row_info_dict = {'gakunin': 20} + filter_key = 'gakunin' + nt.assert_equal(get_rowspan(row_info_dict, filter_key), 20) + + # Key does not exist in dict + row_info_dict = {'openidp': 5} + filter_key = 'gakunin' + nt.assert_equal(get_rowspan(row_info_dict, filter_key), 1) + + +class TestServiceAccessControlSettingView(AdminTestCase): + def setUp(self): + super(TestServiceAccessControlSettingView, self).setUp() + + self.request = RequestFactory().get(reverse('service_access_control_setting:list')) + self.institution = InstitutionFactory() + self.user = AuthUserFactory(is_active=True, is_registered=True) + self.user.affiliated_institutions.add(self.institution) + self.user.save() + self.auth = Auth(self.user) + self.service_access_control_setting = ServiceAccessControlSettingFactory() + FunctionFactory(service_access_control_setting=self.service_access_control_setting) + self.view = ServiceAccessControlSettingView() + self.view = setup_user_view(self.view, self.request, user=self.user) + self.mock_config_json = json.dumps({ + 'function_001': { + 'function_name': 'test', + 'api_group': [{ + 'api': '/search/', + 'method': 'GET', + }] + } + }) + + def test_unauthorized(self): + self.request.user = AnonymousUser() + nt.assert_false(self.view.test_func()) + nt.assert_false(self.view.raise_exception) + + def test_normal_user_login(self): + nt.assert_false(self.view.test_func()) + nt.assert_true(self.view.raise_exception) + + def test_admin_login(self): + self.request.user.is_superuser = False + self.request.user.is_staff = True + nt.assert_true(self.view.test_func()) + + def test_super_login(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + nt.assert_true(self.view.test_func()) + + def test_get_queryset__super_admin(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + res = self.view.get_queryset() + nt.assert_equal(len(res), 1) + nt.assert_equal(res[0], self.service_access_control_setting) + + def test_get_queryset__admin(self): + self.request.user.is_superuser = False + self.request.user.is_staff = True + service_access_control_setting = ServiceAccessControlSettingFactory(institution_id=self.institution.guid) + FunctionFactory(service_access_control_setting=service_access_control_setting) + res = self.view.get_queryset() + nt.assert_equal(len(res), 1) + nt.assert_equal(res[0], service_access_control_setting) + + def test_get_queryset__none(self): + res = self.view.get_queryset() + nt.assert_equal(len(res), 0) + + def test_get_context_data(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + self.view.object_list = self.view.get_queryset() + res = self.view.get_context_data() + mock_open_file.assert_called() + nt.assert_is_not_none(res) + nt.assert_not_equal(res['column_data'], {}) + nt.assert_not_equal(res['row_data'], []) + + def test_get_context_data__read_config_file_error(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + with mock.patch('admin.service_access_control_setting.views.from_json') as mock_from_json: + mock_from_json.side_effect = ValueError('test fail to load file') + self.view.object_list = self.view.get_queryset() + res = self.view.get_context_data() + mock_open_file.assert_called() + mock_from_json.assert_called() + nt.assert_is_not_none(res) + nt.assert_equal(res['column_data'], {}) + nt.assert_equal(res['row_data'], []) + + +class TestServiceAccessControlSettingCreateView(AdminTestCase): + def setUp(self): + super(TestServiceAccessControlSettingCreateView, self).setUp() + + self.institution = InstitutionFactory() + test_dict = { + 'data': [ + { + 'institution_id': self.institution.guid, + 'domain': 'test.com', + 'is_ial2_or_aal2': True, + 'user_domain': '@test.com', + 'project_limit_number': 10, + 'is_whitelist': False, + 'function_codes': ['function_001'] + } + ] + } + binary_json = json.dumps(test_dict).encode('utf-8') + self.upload_file = SimpleUploadedFile('file.json', binary_json) + self.user = AuthUserFactory(is_active=True, is_registered=True) + self.user.affiliated_institutions.add(self.institution) + self.user.save() + self.auth = Auth(self.user) + self.request = RequestFactory().post(reverse('service_access_control_setting:create_setting')) + self.request.FILES['file'] = self.upload_file + self.view = ServiceAccessControlSettingCreateView() + self.view = setup_user_view(self.view, self.request, user=self.user) + self.mock_config_json = json.dumps({ + 'function_001': { + 'function_name': 'test', + 'api_group': [{ + 'api': r'^\/test\/$', + 'method': 'GET', + }] + } + }) + + def test_unauthorized(self): + self.request.user = AnonymousUser() + nt.assert_false(self.view.test_func()) + nt.assert_false(self.view.raise_exception) + + def test_normal_user_login(self): + nt.assert_false(self.view.test_func()) + nt.assert_true(self.view.raise_exception) + + def test_admin_login(self): + self.request.user.is_superuser = False + self.request.user.is_staff = True + nt.assert_false(self.view.test_func()) + nt.assert_true(self.view.raise_exception) + + def test_super_login(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + nt.assert_true(self.view.test_func()) + + def test_post(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + res = self.view.post(self.request) + mock_open_file.assert_called() + nt.assert_equal(res.status_code, http_status.HTTP_200_OK) + nt.assert_equal(res.content, b'{}') + + def test_post__read_upload_file_error(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + self.request.FILES['file'] = SimpleUploadedFile('text.txt', b'text') + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + with nt.assert_raises(Exception): + self.view.post(self.request) + mock_open_file.assert_not_called() + + def test_post__validate_upload_file_fail(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + self.request.FILES['file'] = SimpleUploadedFile('text.json', b'{}') + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + res = self.view.post(self.request) + mock_open_file.assert_not_called() + nt.assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST) + nt.assert_equal(res.content, b'{"message": "JSON file is invalid."}') + + def test_post__read_config_file_error(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + mock_open_file.side_effect = ValueError('test read config file fail') + res = self.view.post(self.request) + mock_open_file.assert_called() + nt.assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST) + nt.assert_equal(res.content, b'{"message": "Config data is invalid."}') + + def test_post__institution_id_not_found(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + test_dict = { + 'data': [ + { + 'institution_id': f'{self.institution.guid}+', + 'domain': 'test.com', + 'is_ial2_or_aal2': True, + 'user_domain': '@test.com', + 'project_limit_number': 10, + 'is_whitelist': False, + 'function_codes': ['function_001'] + } + ] + } + binary_json = json.dumps(test_dict).encode('utf-8') + self.upload_file = SimpleUploadedFile('file.json', binary_json) + self.request.FILES['file'] = self.upload_file + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + res = self.view.post(self.request) + mock_open_file.assert_called() + nt.assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST) + nt.assert_equal(res.content, b'{"message": "JSON file is invalid."}') + + def test_post__function_code_not_in_config(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + mock_config_json = json.dumps({ + 'function_002': { + 'function_name': 'test not exist function code', + 'api_group': [{ + 'api': r'^\/[a-z0-9A-Z]+\/addons\/?$', + 'method': 'GET', + }] + } + }) + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=mock_config_json)) as mock_open_file: + res = self.view.post(self.request) + mock_open_file.assert_called() + nt.assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST) + nt.assert_equal(res.content, b'{"message": "JSON file is invalid."}') + + def test_post__not_unique_together(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + test_dict = { + 'data': [ + { + 'institution_id': self.institution.guid, + 'domain': 'test.com', + 'is_ial2_or_aal2': True, + 'user_domain': '@test.com', + 'project_limit_number': 10, + 'is_whitelist': False, + 'function_codes': ['function_001'] + }, + { + 'institution_id': self.institution.guid, + 'domain': 'test.com', + 'is_ial2_or_aal2': True, + 'user_domain': '@test.com', + 'is_whitelist': True, + 'function_codes': ['function_001'] + } + ] + } + binary_json = json.dumps(test_dict).encode('utf-8') + self.upload_file = SimpleUploadedFile('file.json', binary_json) + self.request.FILES['file'] = self.upload_file + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + res = self.view.post(self.request) + mock_open_file.assert_called() + nt.assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST) + nt.assert_equal(res.content, b'{"message": "JSON file is invalid."}') + + @mock.patch.object(ServiceAccessControlSetting.objects, 'bulk_create') + def test_post__transaction_error(self, mock_bulk_create): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + mock_bulk_create.side_effect = IntegrityError('test existed pk') + with nt.assert_raises(IntegrityError): + self.view.post(self.request) + mock_open_file.assert_called() + + def test_parse_file(self): + nt.assert_is_not_none(self.view.parse_file(self.upload_file)) diff --git a/api/base/middleware.py b/api/base/middleware.py index 6839caa0f5c..0a1477fc770 100644 --- a/api/base/middleware.py +++ b/api/base/middleware.py @@ -22,15 +22,19 @@ celery_after_request, celery_teardown_request, ) +from framework.function_control.handlers import ( + check_api_service_access, +) from .api_globals import api_globals from api.base import settings as api_settings from waffle.middleware import WaffleMiddleware from waffle.models import Flag -from website.settings import DOMAIN +from website.settings import DOMAIN, COOKIE_NAME from osf.models import ( Preprint, PreprintProvider, + OSFUser, ) from typing import Optional @@ -410,3 +414,14 @@ def set_sloan_cookie(self, name: str, value, url, request, resp, custom_domain=N # Browsers won't allow use to use these cookie attributes unless you're sending the data over https. resp.cookies[name]['secure'] = True resp.cookies[name]['samesite'] = 'None' + + +class ServiceAccessControlMiddleware(MiddlewareMixin): + """Service access control middleware.""" + def process_request(self, request): + # Get user information from request + cookie_value = request.COOKIES.get(COOKIE_NAME) + user = OSFUser.from_cookie(cookie_value) + # Check user's API permission + error_response = check_api_service_access(request.path, request.method, user) + return error_response diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index c9b191331d9..d4c10a6dfff 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -244,6 +244,8 @@ 'django.middleware.security.SecurityMiddleware', # 'waffle.middleware.WaffleMiddleware', 'api.base.middleware.SloanOverrideWaffleMiddleware', # Delete this and uncomment WaffleMiddleware to revert Sloan + # Middleware for checking API v2 access for user + 'api.base.middleware.ServiceAccessControlMiddleware', ) TEMPLATES = [ @@ -498,3 +500,87 @@ BASE_FOR_METRIC_PREFIX = 1000 SIZE_UNIT_GB = BASE_FOR_METRIC_PREFIX ** 3 NII_STORAGE_REGION_ID = 1 + +# List of API that will return error message if user is denied by ServiceAccessControlSetting +ERROR_MESSAGE_API_LIST = [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/?$', + 'method': 'PUT', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/?$', + 'method': 'DELETE', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/edit/?$', + 'method': 'POST', + }, + { + 'api': r'^/v2/nodes/[a-z0-9A-Z]+/children/?$', + 'method': 'POST', + }, + { + 'api': r'^/v2/nodes/[a-z0-9A-Z]+/forks/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/new/[a-z0-9A-Z]+/?$', + 'method': 'POST', + }, + { + 'api': r'^/v2/nodes/[a-z0-9A-Z]+/node_links/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/pointer/?$', + 'method': 'DELETE', + }, + { + 'api': r'^/v2/nodes/[a-z0-9A-Z]+/?$', + 'method': 'PATCH', + }, + { + 'api': r'^/api/v1/pointer/?$', + 'method': 'POST', + }, + { + 'api': r'^/v2/collections/[a-z0-9A-Z]+/relationships/linked_nodes/?$', + 'method': 'DELETE', + }, + { + 'api': r'^/v2/nodes/?$', + 'method': 'PATCH', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/invite_contributor/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/contributors/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/contributor/remove/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/contributors/manage/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/private_link/?$', + 'method': 'POST', + }, + { + 'api': r'^/api/v1/project/[a-z0-9A-Z]+/private_link/?$', + 'method': 'DELETE', + }, + { + 'api': r'^/v2/nodes/?$', + 'method': 'DELETE', + }, +] diff --git a/api/nodes/views.py b/api/nodes/views.py index f881bd7f408..e17aaabe8b6 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -8,7 +8,7 @@ from rest_framework import generics, permissions as drf_permissions from rest_framework.exceptions import PermissionDenied, ValidationError, NotFound, MethodNotAllowed, NotAuthenticated from rest_framework.response import Response -from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK +from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK, HTTP_400_BAD_REQUEST from addons.base.exceptions import InvalidAuthError from addons.osfstorage.models import OsfStorageFolder @@ -334,6 +334,13 @@ def perform_create(self, serializer): node.map_group_key = group_key node.save() + def create(self, request, *args, **kwargs): + if not request.user.can_create_new_project: + # If user is not allowed to create new project, return HTTP 400 with type 1 in response + # Type 1: Show project number met limit error message + return Response({'errors': [{'type': 1, 'status': HTTP_400_BAD_REQUEST}]}, status=HTTP_400_BAD_REQUEST) + return super(NodeList, self).create(request, *args, **kwargs) + # overrides BulkDestroyJSONAPIView def allow_bulk_destroy_resources(self, user, resource_list): """User must have admin permissions to delete nodes.""" diff --git a/api/users/urls.py b/api/users/urls.py index 009baf4927e..60697ef2678 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -26,4 +26,5 @@ url(r'^(?P\w+)/settings/identities/(?P\w+)/$', views.UserIdentitiesDetail.as_view(), name=views.UserIdentitiesDetail.view_name), url(r'^(?P\w+)/settings/export/$', views.UserAccountExport.as_view(), name=views.UserAccountExport.view_name), url(r'^(?P\w+)/settings/password/$', views.UserChangePassword.as_view(), name=views.UserChangePassword.view_name), + url(r'^(?P\w+)/settings/create-project-permission/$', views.UserCreateProjectPermission.as_view(), name=views.UserChangePassword.view_name), ] diff --git a/api/users/views.py b/api/users/views.py index 88bbd191bb0..8bff438ff75 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -934,3 +934,22 @@ def perform_destroy(self, instance): else: user.remove_unconfirmed_email(email) user.save() + + +class UserCreateProjectPermission(JSONAPIBaseView, generics.RetrieveAPIView, UserMixin): + """ API view that return can_create_new_project mainly used for ember-osf-web """ + permission_classes = ( + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + CurrentUser, + ) + + required_read_scopes = [CoreScopes.USER_SETTINGS_READ] + required_write_scopes = [CoreScopes.NULL] + + view_category = 'users' + view_name = 'user-create-project-permission' + + def get(self, request, *args, **kwargs): + """Return if user's create new project permission""" + return Response({'can_create_new_project': self.get_user().can_create_new_project}, status=status.HTTP_200_OK) diff --git a/api_tests/base/test_middleware.py b/api_tests/base/test_middleware.py index 57d780974e0..af0ee4c61ff 100644 --- a/api_tests/base/test_middleware.py +++ b/api_tests/base/test_middleware.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from future.moves.urllib.parse import urlparse +import json import mock from nose.tools import * # noqa: +from rest_framework import status as http_status from rest_framework.test import APIRequestFactory from django.test.utils import override_settings from website.util import api_v2_url from api.base import settings -from api.base.middleware import CorsMiddleware +from api.base.middleware import CorsMiddleware, ServiceAccessControlMiddleware from tests.base import ApiTestCase from osf_tests import factories @@ -113,3 +115,42 @@ def test_non_institution_preflight_request_requesting_authorization_header_gets_ self.middleware.process_request(request) self.middleware.process_response(request, response) assert_equal(response['Access-Control-Allow-Origin'], domain.geturl()) + + +class TestServiceAccessControlMiddleware(MiddlewareTestCase): + MIDDLEWARE = ServiceAccessControlMiddleware + + def test_process_request(self): + with mock.patch('api.base.middleware.check_api_service_access') as mock_check_access: + mock_check_access.return_value = None + url = api_v2_url('users/me/') + domain = urlparse('https://dinosaurs.sexy') + request = self.request_factory.options( + url, + HTTP_ORIGIN=domain.geturl(), + HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET', + HTTP_ACCESS_CONTROL_REQUEST_HEADERS='authorization' + ) + result = self.middleware.process_request(request) + assert mock_check_access.called + assert result is None + + def test_process_request_error(self): + with mock.patch('api.base.middleware.check_api_service_access') as mock_check_access: + mock_check_access.return_value = JsonResponse( + {'errors': [{'message': 'User is not allowed to access this API.', 'status': http_status.HTTP_403_FORBIDDEN}]}, + status=http_status.HTTP_403_FORBIDDEN) + url = api_v2_url('users/me/') + domain = urlparse('https://dinosaurs.sexy') + request = self.request_factory.options( + url, + HTTP_ORIGIN=domain.geturl(), + HTTP_ACCESS_CONTROL_REQUEST_METHOD='GET', + HTTP_ACCESS_CONTROL_REQUEST_HEADERS='authorization' + ) + result = self.middleware.process_request(request) + assert mock_check_access.called + assert result is not None + assert result.status_code == http_status.HTTP_403_FORBIDDEN + json_response = json.loads(result.content.decode('utf-8')) + assert len(json_response.get('errors', [])) == 1 diff --git a/api_tests/nodes/views/test_node_list.py b/api_tests/nodes/views/test_node_list.py index 922f05b1fe1..9ecec24a7ba 100644 --- a/api_tests/nodes/views/test_node_list.py +++ b/api_tests/nodes/views/test_node_list.py @@ -1,3 +1,4 @@ +import mock import pytest from nose.tools import * # noqa: @@ -1829,6 +1830,17 @@ def test_create_project_with_bad_region_query_param( assert res.status_code == 400 assert res.json['errors'][0]['detail'] == 'Region {} is invalid.'.format(bad_region_id) + def test_create_project__user_cannot_create_new_project(self, app, user_one, region, private_project, url): + with mock.patch('osf.models.user.OSFUser.can_create_new_project', new_callable=mock.PropertyMock) as mock_can_create_new_project: + mock_can_create_new_project.return_value = False + res = app.post_json_api( + url, private_project, auth=user_one.auth, + expect_errors=True + ) + assert mock_can_create_new_project.called + assert res.status_code == 400 + assert res.json['errors'][0]['type'] == 1 + def test_create_project_errors( self, app, user_one, title, description, category, url): diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index 3057881ed16..9262187ab94 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -601,3 +601,26 @@ def test_resend_confirmation_email(self, mock_send_confirm_email, app, user_one, res = app.get(url, auth=user_one.auth) assert mock_send_confirm_email.call_count == call_count assert res.status_code == 200 + + +@pytest.mark.django_db +class TestUserCreateProjectPermission: + @pytest.fixture() + def url(self, user_one): + return '/{}users/{}/settings/create-project-permission/'.format(API_BASE, user_one._id) + + def test_get_true(self, app, url, user_one): + with mock.patch('osf.models.user.OSFUser.can_create_new_project', new_callable=mock.PropertyMock) as mock_can_create_new_project: + mock_can_create_new_project.return_value = True + res = app.get(url, auth=user_one.auth) + assert mock_can_create_new_project.called + assert res.status_code == 200 + assert res.json.get('can_create_new_project') is True + + def test_get_false(self, app, url, user_one): + with mock.patch('osf.models.user.OSFUser.can_create_new_project', new_callable=mock.PropertyMock) as mock_can_create_new_project: + mock_can_create_new_project.return_value = False + res = app.get(url, auth=user_one.auth) + assert mock_can_create_new_project.called + assert res.status_code == 200 + assert res.json.get('can_create_new_project') is False diff --git a/framework/function_control/__init__.py b/framework/function_control/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/framework/function_control/handlers.py b/framework/function_control/handlers.py new file mode 100644 index 00000000000..e53f027d5fe --- /dev/null +++ b/framework/function_control/handlers.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +import logging +import json +import os +import jsonschema +import re + +from django.http import JsonResponse +from flask import request, abort, Response +from rest_framework import status as http_status +from admin.base.schemas.utils import from_json +from admin.base.settings import BASE_DIR +from framework.flask import redirect + +logger = logging.getLogger(__name__) + + +def check_api_service_access(request_url_path, request_method, user): + """ Check if user has access to API """ + from admin.service_access_control_setting.views import CONFIG_PATH, CONFIG_SCHEMA_FILE_NAME + from api.base.settings import ERROR_MESSAGE_API_LIST + if user is None: + # If there is no user information, return None + return None + + try: + # Load config data file + with open(os.path.join(BASE_DIR, CONFIG_PATH), encoding='utf-8') as fp: + function_config_json = json.load(fp) + # Load config data schema json file + schema = from_json(CONFIG_SCHEMA_FILE_NAME) + # Validate config data with the JSON schema + jsonschema.validate(function_config_json, schema) + except Exception as e: + # Fail to load or validate config data, return None + logger.warning(f'Failed to load config schema with exception {e}') + return None + + # Find a function_code in config data + request_function_code = None + for function_code, function_config in function_config_json.items(): + for api_item in function_config.get('api_group', []): + try: + api_pattern = re.compile(api_item.get('api')) + api_method = api_item.get('method') + if api_pattern.match(request_url_path) and api_method == request_method: + request_function_code = function_code + break + except re.error: + # If API string is not a regex, skip this item + continue + + if not user.is_allowed_to_access_api(request_function_code): + # If user is not allowed to access API, return HTTP 403 response + # If error_type = 0: instruct client not to show error message + # If error_type = 1: instruct client to show error message + error_type = 0 + try: + # Check if request URL and request method is in API group list that will return error_message + for api_item in ERROR_MESSAGE_API_LIST: + try: + api_pattern = re.compile(api_item.get('api')) + api_method = api_item.get('method') + if api_pattern.match(request_url_path) and api_method == request_method: + # If request URL is in API group list setting, set error_type to 1 + error_type = 1 + break + except re.error: + # If API string is not a regex, skip this item + continue + except Exception as e: + # If other exception raised, stop checking + logger.warning(f'Error occurred while checking request URL with error message API list setting: {e}') + + # Return HTTP 403 error response with correspond error_type + return JsonResponse({ + 'errors': [{ + 'message': 'User is not allowed to access this API.', + 'type': error_type, + 'status': http_status.HTTP_403_FORBIDDEN + }] + }, status=http_status.HTTP_403_FORBIDDEN) + return None + + +def function_control_before_request(): + """ Check function access control before request function """ + from osf.models import OSFUser + from website.settings import COOKIE_NAME + + # Get user information from request + cookie_value = request.cookies.get(COOKIE_NAME) + user = OSFUser.from_cookie(cookie_value) + # Check user's API permission + error_response = check_api_service_access(request.path, request.method, user) + if error_response: + # If there is error response, handle that error response + error_dict = json.loads(error_response.content.decode('utf-8')) + error_type = error_dict.get('errors', [{}])[0].get('type') + if request.accept_mimetypes['text/html'] > request.accept_mimetypes['application/json'] and error_type == 0: + # If request want to get HTML response and error_type = 0, redirect to 403 page + return redirect('/403') + else: + # Otherwise, return JSON response + abort(Response(response=error_response.content, status=http_status.HTTP_403_FORBIDDEN, content_type='application/json')) + + +handlers = { + 'before_request': function_control_before_request, +} diff --git a/osf/migrations/0233_auto_20240227_0348.py b/osf/migrations/0233_auto_20240227_0348.py new file mode 100644 index 00000000000..ea96fb03fbf --- /dev/null +++ b/osf/migrations/0233_auto_20240227_0348.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2024-02-27 03:48 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0232_auto_20230830_0425'), + ] + + operations = [ + migrations.CreateModel( + name='Function', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('function_code', models.CharField(max_length=255)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ], + options={ + 'ordering': ['pk'], + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='ServiceAccessControlSetting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('institution_id', models.CharField(max_length=255)), + ('domain', models.CharField(max_length=255)), + ('is_ial2_or_aal2', models.BooleanField()), + ('user_domain', models.CharField(max_length=255)), + ('project_limit_number', models.IntegerField(null=True)), + ('is_whitelist', models.BooleanField()), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ], + options={ + 'db_table': 'osf_service_access_control_setting', + 'ordering': ['pk'], + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.AddField( + model_name='osfuser', + name='aal', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='osfuser', + name='ial', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddIndex( + model_name='serviceaccesscontrolsetting', + index=models.Index(fields=['institution_id', 'domain', 'is_ial2_or_aal2', 'user_domain'], name='osf_service_institu_0bf9fc_idx'), + ), + migrations.AddField( + model_name='function', + name='service_access_control_setting', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='functions', to='osf.ServiceAccessControlSetting'), + ), + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index c1275a1f4d3..a2d5548ab3f 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -67,3 +67,5 @@ from osf.models.export_data_location import ExportDataLocation # noqa from osf.models.export_data import ExportData # noqa from osf.models.export_data_restore import ExportDataRestore # noqa +from osf.models.function import Function # noqa +from osf.models.service_access_control_setting import ServiceAccessControlSetting # noqa diff --git a/osf/models/function.py b/osf/models/function.py new file mode 100644 index 00000000000..1edd2962b71 --- /dev/null +++ b/osf/models/function.py @@ -0,0 +1,13 @@ +from django.db import models + +from osf.models.base import BaseModel +from osf.models.service_access_control_setting import ServiceAccessControlSetting + + +class Function(BaseModel): + function_code = models.CharField(max_length=255) + service_access_control_setting = models.ForeignKey(ServiceAccessControlSetting, related_name='functions', on_delete=models.CASCADE) + is_deleted = models.BooleanField(default=False, db_index=True) + + class Meta: + ordering = ['pk'] diff --git a/osf/models/service_access_control_setting.py b/osf/models/service_access_control_setting.py new file mode 100644 index 00000000000..a2719403418 --- /dev/null +++ b/osf/models/service_access_control_setting.py @@ -0,0 +1,19 @@ +from django.db import models +from osf.models.base import BaseModel + + +class ServiceAccessControlSetting(BaseModel): + institution_id = models.CharField(max_length=255) + domain = models.CharField(max_length=255) + is_ial2_or_aal2 = models.BooleanField() + user_domain = models.CharField(max_length=255) + project_limit_number = models.IntegerField(null=True) + is_whitelist = models.BooleanField() + is_deleted = models.BooleanField(default=False, db_index=True) + + class Meta: + db_table = 'osf_service_access_control_setting' + ordering = ['pk'] + indexes = [ + models.Index(fields=['institution_id', 'domain', 'is_ial2_or_aal2', 'user_domain']), + ] diff --git a/osf/models/user.py b/osf/models/user.py index 2725f9360be..bc8e639a8aa 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -23,6 +23,7 @@ from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.hashers import check_password from django.contrib.auth.models import PermissionsMixin +from django.contrib.postgres.aggregates import ArrayAgg from django.dispatch import receiver from django.db import models from django.db.models import Count @@ -472,6 +473,10 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi mapcore_api_locked = models.BooleanField(default=False) mapcore_refresh_locked = models.BooleanField(default=False) + # @R2022-48 eduPersonAssurance(ial),AuthnContextClass(aal) from Shibboleth + ial = models.CharField(blank=True, max_length=255, null=True) + aal = models.CharField(blank=True, max_length=255, null=True) + def __repr__(self): return ''.format(self.username, self._id) @@ -1881,6 +1886,79 @@ def is_allowed_to_use_institution(self, institution): """Return if this user is supper or is admin affiliated with ``institution``.""" return self.is_super_admin or (self.is_admin and self.is_affiliated_with_institution(institution)) + def _get_match_service_access_control_setting_queryset(self): + """Get user's best match service access control setting queryset""" + from osf.models import ServiceAccessControlSetting + # Get user's institution GUID + affiliated_institution = self.representative_affiliated_institution + affiliated_institution_guid = affiliated_institution.guid if affiliated_institution is not None else None + + # Get user's domain from eppn + domain = None + if self.eppn: + eppn_string_split = self.eppn.split('@', 1) + if len(eppn_string_split) > 1: + domain = eppn_string_split[1] + + # Get user's is_ial2_or_aal2 status + user_is_ial2_or_aal2 = not (self.ial is None and self.aal is None) + # Create common queryset to get best match service access control setting for current user + query_set = ServiceAccessControlSetting.objects.filter( + institution_id=affiliated_institution_guid, + domain__in=[domain, 'default'], + is_ial2_or_aal2=user_is_ial2_or_aal2, + is_deleted=False, + ).annotate( + email_domain=models.Value(self.username, output_field=models.CharField()) + ).filter( + models.Q(email_domain__endswith=models.F('user_domain')) | models.Q(user_domain='default') + ).annotate( + domain_order=models.Case( + models.When(domain='default', then=models.Value(2)), + default=models.Value(1), + output_field=models.IntegerField(), + ), + user_domain_order=models.Case( + models.When(user_domain='default', then=models.Value(2)), + default=models.Value(1), + output_field=models.IntegerField(), + ), + ).order_by('domain_order', 'user_domain_order') + return query_set + + @property + def can_create_new_project(self): + """Check and return boolean if this user is allowed to create a new project""" + from osf.models import Node + # Get project limit number from the best match service access control setting if have + query_set = self._get_match_service_access_control_setting_queryset() + project_limit_number = query_set.values_list('project_limit_number', flat=True).first() + # Set default permission value to True + can_create_new_project = True + if project_limit_number is not None: + # If there is project limit number for current user, compare it to number of created project by current user + project_created_number = Node.objects.filter(creator=self, is_deleted=False).count() + can_create_new_project = project_created_number < project_limit_number + return can_create_new_project + + def is_allowed_to_access_api(self, function_code): + """Check and return boolean if this user is allowed to access api in function code""" + if not function_code: + # Invalid function code, return True + return True + # Get is_whitelist and function_codes from the best match service access control setting if have + query_set = self._get_match_service_access_control_setting_queryset() + match_setting = query_set.annotate(function_codes=ArrayAgg('functions__function_code')).values('is_whitelist', 'function_codes').first() + # Set default permission value to True + is_allowed_to_access_api = True + if match_setting is not None: + is_whitelist = match_setting.get('is_whitelist') + # Check if provided function_code in the function codes list + is_function_code_in_list = function_code in match_setting.get('function_codes', []) + # User is allowed to access API if both is_whitelist and is_function_code_in_list are True or False + is_allowed_to_access_api = is_whitelist == is_function_code_in_list + return is_allowed_to_access_api + def update_affiliated_institutions_by_email_domain(self): """ Append affiliated_institutions by email domain. diff --git a/osf_tests/factories.py b/osf_tests/factories.py index c17f354d7eb..bc3612b6ab6 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -1224,3 +1224,22 @@ class Meta: class RdmFileTimestamptokenVerifyResultFactory(DjangoModelFactory): class Meta: model = models.RdmFileTimestamptokenVerifyResult + + +class ServiceAccessControlSettingFactory(DjangoModelFactory): + institution_id = factory.Faker('text') + domain = factory.Faker('text') + is_ial2_or_aal2 = False + user_domain = factory.Faker('text') + is_whitelist = False + + class Meta: + model = models.ServiceAccessControlSetting + + +class FunctionFactory(DjangoModelFactory): + function_code = factory.Faker('text') + service_access_control_setting = factory.SubFactory(ServiceAccessControlSettingFactory) + + class Meta: + model = models.Function diff --git a/osf_tests/test_app.py b/osf_tests/test_app.py index 24b51636841..c7c290b3356 100644 --- a/osf_tests/test_app.py +++ b/osf_tests/test_app.py @@ -25,6 +25,7 @@ def test_attach_handlers(): framework.sessions.prepare_private_key, framework.sessions.before_request, framework.csrf.handlers.before_request, + framework.function_control.handlers.function_control_before_request, } assert_after_funcs = { diff --git a/osf_tests/test_function.py b/osf_tests/test_function.py new file mode 100644 index 00000000000..aa7cfe85a15 --- /dev/null +++ b/osf_tests/test_function.py @@ -0,0 +1,30 @@ +import pytest +from django.db import IntegrityError + +from osf.models import Function +from osf_tests.factories import ServiceAccessControlSettingFactory + + +@pytest.mark.django_db +class TestFunction: + def test_has_an_integer_pk(self): + service_access_control_setting = ServiceAccessControlSettingFactory() + function = Function(function_code='test', service_access_control_setting=service_access_control_setting) + function.save() + assert type(function.pk) is int + + def test_validation__no_values(self): + function = Function() + with pytest.raises(IntegrityError): + function.save() + + def test_validation__no_related_service_acccess_control_setting(self): + function = Function(function_code='test') + with pytest.raises(IntegrityError): + function.save() + + def test_validation__no_function_code(self): + service_access_control_setting = ServiceAccessControlSettingFactory() + function = Function(function_code=None, service_access_control_setting=service_access_control_setting) + with pytest.raises(IntegrityError): + function.save() diff --git a/osf_tests/test_service_access_control_setting.py b/osf_tests/test_service_access_control_setting.py new file mode 100644 index 00000000000..73866ca0863 --- /dev/null +++ b/osf_tests/test_service_access_control_setting.py @@ -0,0 +1,80 @@ +import pytest +from django.db import IntegrityError + +from osf.models import ServiceAccessControlSetting + + +@pytest.mark.django_db +class TestServiceAccessControlSetting: + def test_has_an_integer_pk(self): + setting = ServiceAccessControlSetting( + institution_id='gakunin', + domain='test.com', + is_ial2_or_aal2=True, + user_domain='example.com', + is_whitelist=True + ) + setting.save() + assert type(setting.pk) is int + + def test_validation__no_values(self): + setting = ServiceAccessControlSetting() + with pytest.raises(IntegrityError): + setting.save() + + def test_validation__no_institution_id(self): + setting = ServiceAccessControlSetting( + institution_id=None, + domain='test.com', + is_ial2_or_aal2=True, + user_domain='example.com', + is_whitelist=True + + ) + with pytest.raises(IntegrityError): + setting.save() + + def test_validation__no_domain(self): + setting = ServiceAccessControlSetting( + institution_id='gakunin', + domain=None, + is_ial2_or_aal2=True, + user_domain='example.com', + is_whitelist=True + ) + with pytest.raises(IntegrityError): + setting.save() + + def test_validation__no_is_ial2_or_aal2(self): + # Null is_ial2_or_aal2 + setting = ServiceAccessControlSetting( + institution_id='gakunin', + domain='test.com', + is_ial2_or_aal2=None, + user_domain='example.com', + is_whitelist=True + ) + with pytest.raises(IntegrityError): + setting.save() + + def test_validation__no_user_domain(self): + setting = ServiceAccessControlSetting( + institution_id='gakunin', + domain='test.com', + is_ial2_or_aal2=True, + user_domain=None, + is_whitelist=True + ) + with pytest.raises(IntegrityError): + setting.save() + + def test_validation__no_is_whitelist(self): + setting = ServiceAccessControlSetting( + institution_id='gakunin', + domain='test.com', + is_ial2_or_aal2=True, + user_domain='example.com', + is_whitelist=None + ) + with pytest.raises(IntegrityError): + setting.save() diff --git a/osf_tests/test_user.py b/osf_tests/test_user.py index a47669155b9..5d80be23f47 100644 --- a/osf_tests/test_user.py +++ b/osf_tests/test_user.py @@ -40,6 +40,7 @@ PreprintContributor, DraftRegistrationContributor, Institution, + ServiceAccessControlSetting, ) from addons.github.tests.factories import GitHubAccountFactory from addons.osfstorage.models import Region @@ -71,6 +72,8 @@ PreprintFactory, ExportDataLocationFactory, RegionFactory, + ServiceAccessControlSettingFactory, + FunctionFactory, ) from tests.base import OsfTestCase from tests.utils import run_celery_tasks @@ -2852,3 +2855,79 @@ def test_check_spam(self, mock_do_check_spam, user): with mock.patch('osf.models.OSFUser._get_spam_content', mock.Mock(return_value='some content!')): user.check_spam(saved_fields={'schools': ['one']}, request_headers=None) assert mock_do_check_spam.call_count == 1 + + +class TestUserServiceAccessControlPermission: + @pytest.fixture + def user(self): + institution = InstitutionFactory() + user = AuthUserFactory() + user.affiliated_institutions.add(institution) + user.eppn = 'test@test.com' + user.save() + return user + + @pytest.fixture + def service_access_control_setting(self, user): + return ServiceAccessControlSettingFactory( + institution_id=user.representative_affiliated_institution.guid, + domain='default', + user_domain='default', + is_whitelist=True + ) + + @pytest.fixture + def function(self, service_access_control_setting): + return FunctionFactory(function_code='test_001', service_access_control_setting=service_access_control_setting) + + def test__get_match_service_access_control_setting_queryset(self, user, service_access_control_setting, function): + queryset = user._get_match_service_access_control_setting_queryset() + assert queryset.exists() + + def test_can_create_new_project__no_record(self, user): + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.none() + assert_equal(user.can_create_new_project, True) + mock_queryset.assert_called() + + def test_can_create_new_project__true(self, user, service_access_control_setting): + service_access_control_setting.project_limit_number = 100 + service_access_control_setting.save() + + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.filter(id=service_access_control_setting.id) + assert_equal(user.can_create_new_project, True) + mock_queryset.assert_called() + + def test_can_create_new_project__false(self, user, service_access_control_setting): + service_access_control_setting.project_limit_number = 0 + service_access_control_setting.save() + + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.filter(id=service_access_control_setting.id) + assert_equal(user.can_create_new_project, False) + mock_queryset.assert_called() + + def test_is_allowed_to_access_api__no_function_code(self, user, service_access_control_setting, function): + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.filter(id=service_access_control_setting.id) + assert_equal(user.is_allowed_to_access_api(None), True) + mock_queryset.assert_not_called() + + def test_is_allowed_to_access_api__no_record(self, user, service_access_control_setting, function): + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.none() + assert_equal(user.is_allowed_to_access_api('test_001'), True) + mock_queryset.assert_called() + + def test_is_allowed_to_access_api__true(self, user, service_access_control_setting, function): + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.filter(id=service_access_control_setting.id) + assert_equal(user.is_allowed_to_access_api('test_001'), True) + mock_queryset.assert_called() + + def test_is_allowed_to_access_api__false(self, user, service_access_control_setting, function): + with mock.patch('osf.models.user.OSFUser._get_match_service_access_control_setting_queryset') as mock_queryset: + mock_queryset.return_value = ServiceAccessControlSetting.objects.filter(id=service_access_control_setting.id) + assert_equal(user.is_allowed_to_access_api('test_002'), False) + mock_queryset.assert_called() diff --git a/tests/framework_tests/test_function_control.py b/tests/framework_tests/test_function_control.py new file mode 100644 index 00000000000..fd347d0d91a --- /dev/null +++ b/tests/framework_tests/test_function_control.py @@ -0,0 +1,183 @@ +import json +import unittest + +import mock +import pytest +from django.http import JsonResponse +from flask import Flask + +from framework.flask import add_handlers +from framework.function_control.handlers import handlers, check_api_service_access, function_control_before_request +from framework.routing import Rule, process_rules, json_renderer +from osf_tests.factories import AuthUserFactory, ServiceAccessControlSettingFactory, FunctionFactory, InstitutionFactory +from tests.base import ApiTestCase +from webtest_plus import TestApp + + +@pytest.mark.django_db +class TestFunctionControlUtils(ApiTestCase): + def setUp(self): + self.institution = InstitutionFactory() + self.user = AuthUserFactory() + self.user.affiliated_institutions.add(self.institution) + self.user.save() + self.service_access_control_setting = ServiceAccessControlSettingFactory( + institution_id=self.institution.guid, + domain='default', + user_domain='default', + is_whitelist=True + ) + self.function = FunctionFactory(function_code='function_001', service_access_control_setting=self.service_access_control_setting) + self.test_config_json = json.dumps({ + 'function_001': { + 'function_name': 'test', + 'api_group': [{ + 'api': r'^/test/?', + 'method': 'GET', + }] + } + }) + + def test_check_api_service_access__no_user(self): + assert check_api_service_access('/test/', 'GET', None) is None + + def test_check_api_service_access__config_data_error(self): + with mock.patch('framework.function_control.handlers.open', mock.mock_open(read_data=self.test_config_json)) as mock_open_file: + mock_open_file.side_effect = ValueError('test parse json fail') + assert check_api_service_access('/test/', 'GET', self.user) is None + mock_open_file.assert_called() + + def test_check_api_service_access__user_is_allowed(self): + with mock.patch('framework.function_control.handlers.open', mock.mock_open(read_data=self.test_config_json)) as mock_open_file: + assert check_api_service_access('/test/', 'GET', self.user) is None + mock_open_file.assert_called() + + def test_check_api_service_access__config_invalid_regex(self): + self.test_config_json = json.dumps({ + 'function_002': { + 'function_name': 'test', + 'api_group': [{ + 'api': r'/[test/', + 'method': 'GET', + }] + } + }) + with mock.patch('framework.function_control.handlers.open', mock.mock_open(read_data=self.test_config_json)) as mock_open_file: + assert check_api_service_access('/test/', 'GET', self.user) is None + mock_open_file.assert_called() + + def test_check_api_service_access__user_is_not_allowed_type_0(self): + self.test_config_json = json.dumps({ + 'function_002': { + 'function_name': 'test', + 'api_group': [{ + 'api': r'^/test/?', + 'method': 'GET', + }] + } + }) + with mock.patch('framework.function_control.handlers.open', mock.mock_open(read_data=self.test_config_json)) as mock_open_file: + error_response = check_api_service_access('/test/', 'GET', self.user) + assert error_response is not None + assert error_response.status_code == 403 + error_dict = json.loads(error_response.content.decode('utf-8')) + assert len(error_dict['errors']) == 1 + assert error_dict['errors'][0].get('type') == 0 + mock_open_file.assert_called() + + def test_check_api_service_access__user_is_not_allowed_type_0_invalid_regex(self): + invalid_api_list = [{ + 'api': r'^/[test/$', + 'method': 'GET', + }, None] + self.test_config_json = json.dumps({ + 'function_002': { + 'function_name': 'test', + 'api_group': [{ + 'api': r'^/test/?', + 'method': 'GET', + }] + } + }) + with mock.patch('framework.function_control.handlers.open', mock.mock_open(read_data=self.test_config_json)) as mock_open_file: + with mock.patch('api.base.settings.ERROR_MESSAGE_API_LIST', invalid_api_list): + error_response = check_api_service_access('/test/', 'GET', self.user) + assert error_response is not None + assert error_response.status_code == 403 + error_dict = json.loads(error_response.content.decode('utf-8')) + assert len(error_dict['errors']) == 1 + assert error_dict['errors'][0].get('type') == 0 + mock_open_file.assert_called() + + def test_check_api_service_access__user_is_not_allowed_type_1(self): + api_list = [{ + 'api': r'^/search/$', + 'method': 'GET', + }] + self.test_config_json = json.dumps({ + 'function_002': { + 'function_name': 'test', + 'api_group': api_list + } + }) + with mock.patch('framework.function_control.handlers.open', mock.mock_open(read_data=self.test_config_json)) as mock_open_file: + with mock.patch('api.base.settings.ERROR_MESSAGE_API_LIST', api_list): + error_response = check_api_service_access('/search/', 'GET', self.user) + assert error_response is not None + assert error_response.status_code == 403 + error_dict = json.loads(error_response.content.decode('utf-8')) + assert len(error_dict['errors']) == 1 + assert error_dict['errors'][0].get('type') == 1 + mock_open_file.assert_called() + + +@pytest.mark.django_db +class TestFunctionControlHandler(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.debug = True + add_handlers(self.app, handlers) + + self.webTestApp = TestApp(self.app) + rule = Rule(['/search/'], 'get', {}, renderer=json_renderer) + process_rules(self.app, [rule]) + + @mock.patch('framework.function_control.handlers.check_api_service_access') + def test_function_control_before_request__no_error(self, mock_check_api_service_access): + mock_check_api_service_access.return_value = None + res = self.webTestApp.get('/search/') + assert res.status_code == 200 + assert mock_check_api_service_access.called + + @mock.patch('framework.function_control.handlers.check_api_service_access') + def test_function_control_before_request__error_html(self, mock_check_api_service_access): + mock_check_api_service_access.return_value = JsonResponse({ + 'errors': [{ + 'message': 'User is not allowed to access this API.', + 'type': 0, + 'status': 403 + }] + }, status=403) + headers = { + 'Accept': 'text/html' + } + res = self.webTestApp.get('/search/', headers=headers, expect_errors=True) + assert res.status_code == 302 + assert '/403' in res.location + assert mock_check_api_service_access.called + + @mock.patch('framework.function_control.handlers.check_api_service_access') + def test_function_control_before_request__error_message(self, mock_check_api_service_access): + mock_check_api_service_access.return_value = JsonResponse({ + 'errors': [{ + 'message': 'User is not allowed to access this API.', + 'type': 1, + 'status': 403 + }] + }, status=403) + res = self.webTestApp.get('/search/', expect_errors=True) + assert res.status_code == 403 + assert mock_check_api_service_access.called + + def test_handlers(self): + assert handlers.get('before_request') == function_control_before_request diff --git a/website/app.py b/website/app.py index c3e4133569b..eab3a298d7e 100644 --- a/website/app.py +++ b/website/app.py @@ -21,6 +21,7 @@ from framework.postcommit_tasks import handlers as postcommit_handlers from framework.sentry import sentry from framework.transactions import handlers as transaction_handlers +from framework.function_control import handlers as function_control_handlers # Imports necessary to connect signals from website.archiver import listeners # noqa from website.mails import listeners # noqa @@ -70,6 +71,9 @@ def attach_handlers(app, settings): add_handlers(app, {'before_request': framework.sessions.before_request, 'after_request': framework.sessions.after_request}) + # Attach handler for checking URL access for user + add_handlers(app, function_control_handlers.handlers) + return app diff --git a/website/routes.py b/website/routes.py index b3de0fbefdd..8bc601c4166 100644 --- a/website/routes.py +++ b/website/routes.py @@ -362,6 +362,16 @@ def make_url_map(app): ), ]) + # HTTP error page + process_rules(app, [ + Rule( + '/403', + ['get'], + HTTPError(http_status.HTTP_403_FORBIDDEN), + notemplate + ), + ]) + ### GUID ### process_rules(app, [ diff --git a/website/static/js/addProjectPlugin.js b/website/static/js/addProjectPlugin.js index c95d2017794..039734a0a67 100644 --- a/website/static/js/addProjectPlugin.js +++ b/website/static/js/addProjectPlugin.js @@ -54,7 +54,9 @@ var AddProject = { self.saveResult = m.prop({}); self.errorMessageType = m.prop('unknown'); self.errorMessage = { - 'unknown' : _('There was an unknown error. Please try again later.') + 'unknown' : _('There was an unknown error. Please try again later.'), + 'forbidden' : _('You do not have permission to operate a project.'), + 'create_project_limited' : _('The new project cannot be created due to the created project number is greater than or equal the project number can create.') }; self.userProjects = m.prop([]); // User nodes @@ -121,6 +123,21 @@ var AddProject = { self.isAdding(false); }; var error = function _error (result) { + self.errorMessageType('unknown'); + var errors = result.errors; + if (errors && errors.length > 0) { + var status_code = errors[0].status; + var type = errors[0].type; + if (status_code && status_code === 403) { + if (type === 0) { + window.location.href = '/403'; + return; + } + self.errorMessageType('forbidden'); + } else if (status_code && status_code === 400 && type === 1) { + self.errorMessageType('create_project_limited'); + } + } self.viewState('error'); self.isAdding(false); }; diff --git a/website/static/js/citationList.js b/website/static/js/citationList.js index 60dea56518d..3d0e0441db0 100644 --- a/website/static/js/citationList.js +++ b/website/static/js/citationList.js @@ -109,9 +109,18 @@ var ViewModel = oop.defclass({ if (!self.customCitation()) { self.fetch(); } - }).fail(function() { - $osf.growl('Error', _('Your custom citation not updated. Please refresh the page and try ') + - sprintf(_('again or contact %1$s') , $osf.osfSupportLink()) + _(' if the problem persists.'), 'danger'); + }).fail(function(xhr) { + self.customCitation(self.initialCustomCitation()); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.growl('Error', _('Your custom citation not updated. Please refresh the page and try ') + + sprintf(_('again or contact %1$s') , $osf.osfSupportLink()) + _(' if the problem persists.'), 'danger'); + } }).always(function() { self.loading(false); }); diff --git a/website/static/js/contribAdder.js b/website/static/js/contribAdder.js index a8af7b66611..51ce58fd1f5 100644 --- a/website/static/js/contribAdder.js +++ b/website/static/js/contribAdder.js @@ -455,6 +455,13 @@ AddContributorViewModel = oop.extend(Paginator, { } }).fail(function (xhr, status, error) { var errorMessage = lodashGet(xhr, 'responseJSON.message') || (sprintf(_('There was a problem trying to add contributors%1$s.') , osfLanguage.REFRESH_OR_SUPPORT)); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + errorMessage = _('You do not have permission to operate a project.'); + } $osf.growl(_('Could not add contributors'), errorMessage); Raven.captureMessage(_('Error adding contributors'), { extra: { @@ -502,6 +509,13 @@ AddContributorViewModel = oop.extend(Paginator, { }, onInviteError: function (xhr) { var self = this; + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + self.inviteError(_('You do not have permission to operate a project.')); + } var response = JSON.parse(xhr.responseText); // Update error message self.inviteError(response.message); diff --git a/website/static/js/contribManager.js b/website/static/js/contribManager.js index 6f7fc9a12b0..66eb47cc2ef 100644 --- a/website/static/js/contribManager.js +++ b/website/static/js/contribManager.js @@ -428,9 +428,17 @@ var ContributorsViewModel = function(contributors, adminContributors, user, isRe } }).fail(function(xhr) { var response = xhr.responseJSON; - $osf.growl('Error:', - _('Submission failed: ') + response.message_long - ); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error:', _('You do not have permission to operate a project.')); + } else { + $osf.growl('Error:', + _('Submission failed: ') + response.message_long + ); + } self.forceSubmit(false); }); } diff --git a/website/static/js/contribRemover.js b/website/static/js/contribRemover.js index d567610be09..f5571d52fd4 100644 --- a/website/static/js/contribRemover.js +++ b/website/static/js/contribRemover.js @@ -229,7 +229,15 @@ var RemoveContributorViewModel = oop.extend(Paginator, { } else { window.location.reload(); } }).fail(function(xhr, status, error) { - $osf.growl('Error', _('Unable to delete Contributor')); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.growl('Error', _('Unable to delete Contributor')); + } Raven.captureMessage(_('Could not DELETE Contributor.') + error, { extra: { url: window.contextVars.node.urls.api + 'contributor/remove/', status: status, error: error diff --git a/website/static/js/licensePicker.js b/website/static/js/licensePicker.js index d8ad2a2fc10..e015a785e85 100644 --- a/website/static/js/licensePicker.js +++ b/website/static/js/licensePicker.js @@ -192,7 +192,15 @@ var LicensePicker = oop.extend(ChangeMessageMixin, { onSaveFail: function(xhr, status, error) { var self = this; - self.changeMessage(_('There was a problem updating your license. Please try again.'), 'text-danger', 2500); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + self.changeMessage(_('You do not have permission to operate a project.'), 'text-danger', 2500); + } else { + self.changeMessage(_('There was a problem updating your license. Please try again.'), 'text-danger', 2500); + } Raven.captureMessage(_('Error fetching user profile'), { extra: { diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index 4f52fafeeda..d7997a77d81 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -1110,9 +1110,11 @@ var MyProjects = { m('small.hidden-xs', _('Browse and organize all your projects')) ])), m('.col-xs-4.p-sm', m('.pull-right', m.component(AddProject, { - buttonTemplate: m('.btn.btn-success.btn-success-high-contrast.f-w-xl[data-toggle="modal"][data-target="#addProject"]', {onclick: function() { - $osf.trackClick('myProjects', 'add-project', 'open-add-project-modal'); - }}, _('Create Project')), + buttonTemplate: !window.contextVars.canCreateNewProject ? + m('.btn.btn-success.btn-success-high-contrast.f-w-xl[disabled]', _('Create Project')) : + m('.btn.btn-success.btn-success-high-contrast.f-w-xl[data-toggle="modal"][data-target="#addProject"]', {onclick: function() { + $osf.trackClick('myProjects', 'add-project', 'open-add-project-modal'); + }}, _('Create Project')), parentID: null, modalID: 'addProject', title: _('Create new project'), diff --git a/website/static/js/nodeControl.js b/website/static/js/nodeControl.js index 112c4d2aecb..2cfcd9afd72 100644 --- a/website/static/js/nodeControl.js +++ b/website/static/js/nodeControl.js @@ -91,7 +91,18 @@ var ProjectViewModel = function(data, options) { success: function () { document.location.reload(true); }, - error: $osf.handleEditableError, + error: function (xhr) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + Raven.captureMessage(_('Unexpected error occurred in an editable input')); + return _('You do not have permission to operate a project.'); + } else { + $osf.handleEditableError(xhr); + } + }, placement: 'bottom' }; @@ -157,7 +168,15 @@ var ProjectViewModel = function(data, options) { $osf.postJSON('/api/v1/pointer/', jsonData) .fail(function(data) { self.inDashboard(false); - $osf.handleJSONError(data); + if (data.status === 403) { + var continueHandle = $osf.handleErrorResponse(data); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.handleJSONError(data); + } }); }; /** @@ -170,9 +189,17 @@ var ProjectViewModel = function(data, options) { $osf.ajaxJSON('DELETE', deleteUrl, { 'data': {'data': [{'type':'linked_nodes', 'id': self._id}]}, 'isCors': true - }).fail(function() { + }).fail(function(xhr) { self.inDashboard(true); - $osf.growl('Error', _('The project could not be removed'), 'danger'); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.growl('Error', _('The project could not be removed'), 'danger'); + } }); }; diff --git a/website/static/js/nodesDelete.js b/website/static/js/nodesDelete.js index 307963f4b83..0c2d3bebb98 100644 --- a/website/static/js/nodesDelete.js +++ b/website/static/js/nodesDelete.js @@ -78,10 +78,16 @@ QuickDeleteViewModel.prototype.confirmChanges = function () { }); request.fail( function (xhr, status, error) { var errorMessage = sprintf(_('Unable to delete %1$s') , self.nodeType); - if (xhr.responseJSON && xhr.responseJSON.errors) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + errorMessage = _('You do not have permission to operate a project.'); + } else if (xhr.responseJSON && xhr.responseJSON.errors) { errorMessage = xhr.responseJSON.errors[0].detail; } - $osf.growl(sprintf(_('Problem deleting %1$s') , self.nodeType, errorMessage)); + $osf.growl(sprintf(_('Problem deleting %1$s') , self.nodeType), errorMessage); Raven.captureMessage(sprintf(_('Could not delete %$1s') , self.nodeType), { extra: { url: self.nodeApiUrl, status: status, error: error @@ -338,7 +344,9 @@ function batchNodesDelete(nodes) { }, function (xhr) { $osf.unblock(); var errorMessage = sprintf(_('Unable to delete %1$s') , self.nodeType); - if (xhr.responseJSON && xhr.responseJSON.errors) { + if (xhr.status === 403) { + errorMessage = _('You do not have permission to operate a project.'); + } else if (xhr.responseJSON && xhr.responseJSON.errors) { errorMessage = xhr.responseJSON.errors[0].detail; } $osf.growl(sprintf(_('Problem deleting %1$s') , self.nodeType), errorMessage); diff --git a/website/static/js/nodesPrivacy.js b/website/static/js/nodesPrivacy.js index b6838b27193..4cef47c9844 100644 --- a/website/static/js/nodesPrivacy.js +++ b/website/static/js/nodesPrivacy.js @@ -255,7 +255,13 @@ NodesPrivacyViewModel.prototype.confirmChanges = function() { }).fail(function (xhr) { $osf.unblock(); var errorMessage = _('Unable to update project privacy'); - if (xhr.responseJSON && xhr.responseJSON.errors) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + errorMessage = _('You do not have permission to operate a project.'); + } else if (xhr.responseJSON && xhr.responseJSON.errors) { errorMessage = xhr.responseJSON.errors[0].detail; } $osf.growl(_('Problem changing privacy'), errorMessage); diff --git a/website/static/js/osfHelpers.js b/website/static/js/osfHelpers.js index 71e0a9ac8d2..8c6d103c6b2 100644 --- a/website/static/js/osfHelpers.js +++ b/website/static/js/osfHelpers.js @@ -1061,6 +1061,30 @@ function linkifyText(content) { return linkify(content); } +/** + * Utility function handle error response + * @param xhr {Object} XHR response + * @returns {boolean} a boolean that indicates if redirect to 403 page or show error message + */ +function handleErrorResponse(xhr) { + if (xhr && xhr.status === 403) { + var data = xhr.responseJSON; + if (data && data.errors && data.errors.length > 0) { + var error_message = data.errors[0]; + if (error_message.type && error_message.type === 1) { + // Continue handle on client + return true; + } + } + + // Redirect to HTTP 403 + window.location.href = '/403'; + return false; + } + // Continue handle on client + return true; +} + // Also export these to the global namespace so that these can be used in inline // JS. This is used on the /goodbye page at the moment. module.exports = window.$.osf = { @@ -1113,4 +1137,5 @@ module.exports = window.$.osf = { osfSupportEmail: osfSupportEmail, osfSupportLink: osfSupportLink, refreshOrSupport: refreshOrSupport, + handleErrorResponse: handleErrorResponse, }; diff --git a/website/static/js/pages/project-dashboard-page.js b/website/static/js/pages/project-dashboard-page.js index 6b8453dd08e..88e9d5a36f7 100644 --- a/website/static/js/pages/project-dashboard-page.js +++ b/website/static/js/pages/project-dashboard-page.js @@ -669,6 +669,14 @@ $(document).ready(function () { request.fail(function(xhr, textStatus, error) { window.contextVars.node.tags.splice(window.contextVars.node.tags.indexOf(tag),1); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } + $('#node-tags').importTags(window.contextVars.node.tags.join(',')); Raven.captureMessage(_('Failed to add tag'), { extra: { tag: tag, url: tagsApiUrl, textStatus: textStatus, error: error @@ -704,7 +712,15 @@ $(document).ready(function () { window.contextVars.node.tags.push(tag); // Suppress "tag not found" errors, as the end result is what the user wanted (tag is gone)- eg could be because two people were working at same time if (xhr.status !== 409) { - $osf.growl('Error', _('Could not remove tag')); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.growl('Error', _('Could not remove tag')); + } Raven.captureMessage(_('Failed to remove tag'), { extra: { tag: tag, url: tagsApiUrl, textStatus: textStatus, error: error diff --git a/website/static/js/pointers.js b/website/static/js/pointers.js index 53c11eb58c5..7b4cb3e7765 100644 --- a/website/static/js/pointers.js +++ b/website/static/js/pointers.js @@ -225,7 +225,15 @@ var AddPointerViewModel = oop.extend(Paginator, { self.dirty = true; }); request.fail(function(xhr, status, error){ - self.logErrors(addUrl, status, error, 'Unable to link project'); + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + self.logErrors(addUrl, status, error, _('You do not have permission to operate a project.')); + } else { + self.logErrors(addUrl, status, error, 'Unable to link project'); + } self.processing(false); }); }, diff --git a/website/static/js/privateLinkManager.js b/website/static/js/privateLinkManager.js index c20c58ae1b1..039d4d583ca 100644 --- a/website/static/js/privateLinkManager.js +++ b/website/static/js/privateLinkManager.js @@ -114,7 +114,15 @@ var PrivateLinkViewModel = function(url) { ).done(function() { window.location.reload(); }).fail(function(response) { - self.changeMessage(response.responseJSON.message_long, 'text-danger'); + if (response.status === 403) { + var continueHandle = $osf.handleErrorResponse(response); + if (continueHandle === false) { + return; + } + self.changeMessage(_('You do not have permission to operate a project.'), 'text-danger'); + } else { + self.changeMessage(response.responseJSON.message_long, 'text-danger'); + } self.disableSubmit(false); self.submitText('Create'); }); diff --git a/website/static/js/privateLinkTable.js b/website/static/js/privateLinkTable.js index 2780d89a42c..04f50a43b4b 100644 --- a/website/static/js/privateLinkTable.js +++ b/website/static/js/privateLinkTable.js @@ -105,8 +105,16 @@ function ViewModel(url, nodeIsPublic, table) { data: JSON.stringify(dataToSend) }).done(function() { self.privateLinks.remove(data); - }).fail(function() { - $osf.growl('Error:',_('Failed to delete the private link.')); + }).fail(function(xhr) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error:', _('You do not have permission to operate a project.')); + } else { + $osf.growl('Error:',_('Failed to delete the private link.')); + } }); } }, diff --git a/website/static/js/project.js b/website/static/js/project.js index 8684a52d50a..96bcdb80663 100644 --- a/website/static/js/project.js +++ b/website/static/js/project.js @@ -83,7 +83,15 @@ NodeActions.forkNode = function() { isCors: true, data: payload } - ); + ).fail(function(xhr) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } + }); $osf.growl('Fork status', _('Your fork is being created. You\'ll receive an email when it is complete.'), 'info'); }); }; @@ -168,7 +176,15 @@ NodeActions.useAsTemplate = function() { window.location = response.url; }).fail(function(response) { $osf.unblock(); - $osf.handleJSONError(response); + if (response.status === 403) { + var continueHandle = $osf.handleErrorResponse(response); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.handleJSONError(response); + } }); }); }; @@ -194,9 +210,17 @@ NodeActions.removePointer = function(pointerId, pointerElm) { dataType: 'json' }).done(function() { pointerElm.remove(); - }).fail( - $osf.handleJSONError - ); + }).fail(function(xhr) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error', _('You do not have permission to operate a project.')); + } else { + $osf.handleJSONError(xhr); + } + }); }; // TODO: remove this diff --git a/website/static/js/projectSettings.js b/website/static/js/projectSettings.js index 3293954993a..5dc0f638790 100644 --- a/website/static/js/projectSettings.js +++ b/website/static/js/projectSettings.js @@ -53,7 +53,14 @@ var ProjectSettings = oop.extend( updateError: function(xhr, status, error) { var self = this; var errorMessage; - if (error === 'BAD REQUEST') { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + self.changeMessage(_('You do not have permission to operate a project.'), 'text-danger'); + errorMessage = _('You do not have permission to operate a project.'); + } else if (error === 'BAD REQUEST') { self.changeMessage(language.updateErrorMessage400, 'text-danger'); errorMessage = language.updateErrorMessage400; } @@ -184,7 +191,17 @@ var getConfirmationCode = function(nodeType, isSupplementalProject) { // Redirect to either the parent project or the dashboard window.location.href = response.url; }); - request.fail($osf.handleJSONError); + request.fail(function(xhr) { + if (xhr.status === 403) { + var continueHandle = $osf.handleErrorResponse(xhr); + if (continueHandle === false) { + return; + } + $osf.growl('Error:', _('You do not have permission to operate a project.')); + } else { + $osf.handleJSONError(xhr); + } + }); }, buttons: { success: { diff --git a/website/templates/my_projects.mako b/website/templates/my_projects.mako index 8b5cf3ab80b..290a967195e 100644 --- a/website/templates/my_projects.mako +++ b/website/templates/my_projects.mako @@ -28,6 +28,7 @@ window.contextVars = $.extend(true, {}, window.contextVars, { storageRegions: ${ storage_regions | sjson, n }, storageFlagIsActive: ${ storage_flag_is_active | sjson, n }, + canCreateNewProject: ${ can_create_new_project | sjson, n }, }); diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po index f779a5e4fec..7cfa74483ce 100644 --- a/website/translations/en/LC_MESSAGES/js_messages.po +++ b/website/translations/en/LC_MESSAGES/js_messages.po @@ -2540,6 +2540,14 @@ msgstr "" msgid "There was an unknown error. Please try again later." msgstr "" +#: website/static/js/addProjectPlugin.js:58 +msgid "You do not have permission to operate a project." +msgstr "" + +#: website/static/js/addProjectPlugin.js:59 +msgid "The new project cannot be created due to the created project number is greater than or equal the project number can create." +msgstr "" + #: website/static/js/addProjectPlugin.js:184 website/static/js/conference.js:19 msgid "Title" msgstr "" @@ -7483,3 +7491,13 @@ msgstr "" msgid "Cannot be restored because export data does not exist" msgstr "" + +# message in admin/static/js/service_access_control_setting/service-access-control-setting.js +msgid "Not a JSON file." +msgstr "" + +msgid "JSON file is invalid." +msgstr "" + +msgid "Config data is invalid." +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index 545a64ffa50..63d61ff35db 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -3744,6 +3744,14 @@ msgstr "新規プロジェクト作成" msgid "There was an unknown error. Please try again later." msgstr "不明なエラーがありました。 後でもう一度やり直してください。" +#: website/static/js/addProjectPlugin.js:58 +msgid "You do not have permission to operate a project." +msgstr "プロジェクトを作成する権限がありません。" + +#: website/static/js/addProjectPlugin.js:59 +msgid "The new project cannot be created due to the created project number is greater than or equal the project number can create." +msgstr "作成したプロジェクト数が作成可能なプロジェクトの数以上であるために、新規プロジェクトを作成できません。" + #: website/static/js/addProjectPlugin.js:184 website/static/js/conference.js:19 msgid "Title" msgstr "タイトル" @@ -8726,3 +8734,13 @@ msgstr "このエクスポート プロセスをアボートできません。" msgid "Cannot be restored because export data does not exist" msgstr "エクスポートデータが存在しないため、復元できません。" + +# message in admin/static/js/service_access_control_setting/service-access-control-setting.js +msgid "Not a JSON file." +msgstr "JSONファイルではありません。" + +msgid "JSON file is invalid." +msgstr "JSONァイルが無効です。" + +msgid "Config data is invalid." +msgstr "Configデータが無効です。" diff --git a/website/translations/js_messages.pot b/website/translations/js_messages.pot index 99f53b7da10..da2484e4110 100644 --- a/website/translations/js_messages.pot +++ b/website/translations/js_messages.pot @@ -2491,6 +2491,14 @@ msgstr "" msgid "There was an unknown error. Please try again later." msgstr "" +#: website/static/js/addProjectPlugin.js:58 +msgid "You do not have permission to operate a project." +msgstr "" + +#: website/static/js/addProjectPlugin.js:59 +msgid "The new project cannot be created due to the created project number is greater than or equal the project number can create." +msgstr "" + #: website/static/js/addProjectPlugin.js:184 website/static/js/conference.js:19 msgid "Title" msgstr "" @@ -7426,3 +7434,12 @@ msgstr "" msgid "Cannot be restored because export data does not exist" msgstr "" +# message in admin/static/js/service_access_control_setting/service-access-control-setting.js +msgid "Not a JSON file." +msgstr "" + +msgid "JSON file is invalid." +msgstr "" + +msgid "Config data is invalid." +msgstr "" diff --git a/website/views.py b/website/views.py index cb92b3fe6ca..98eb1ea3188 100644 --- a/website/views.py +++ b/website/views.py @@ -201,6 +201,7 @@ def my_projects(auth): 'dashboard_id': my_projects_id, 'storage_regions': region_list, 'storage_flag_is_active': storage_i18n_flag_active(), + 'can_create_new_project': user.can_create_new_project, } From 3baefcd3317961096d22642c407db502a8ecd922 Mon Sep 17 00:00:00 2001 From: huanphan-tma Date: Thu, 21 Mar 2024 10:22:00 +0700 Subject: [PATCH 2/2] =?UTF-8?q?refs=202.2.1.=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E6=A8=A9=E9=99=90=E8=A8=AD=E5=AE=9A=E6=A9=9F=E8=83=BD:=20Fix?= =?UTF-8?q?=20IT=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/binderhub/static/node-cfg.js | 3 + admin/base/schemas/config-schema.json | 11 +- ...service-access-control-setting-schema.json | 9 +- admin/base/schemas/utils.py | 35 ++++ admin/base/urls.py | 2 +- admin/service_access_control_setting/urls.py | 2 +- admin/service_access_control_setting/views.py | 29 ++-- admin/static/css/institutions.css | 6 + .../service-access-control-setting.js | 15 +- .../service_access_control_setting/list.html | 4 +- admin/translations/django.pot | 3 + admin/translations/en/LC_MESSAGES/django.po | 3 + admin/translations/ja/LC_MESSAGES/django.po | 3 + admin_tests/base/test_utils.py | 155 +++++++++++++++++- .../test_views.py | 18 +- framework/function_control/handlers.py | 7 +- website/static/js/contribAdder.js | 14 +- website/static/js/contribRemover.js | 2 +- website/static/js/osfHelpers.js | 18 +- .../static/js/pages/project-addons-page.js | 5 +- .../static/js/pages/project-dashboard-page.js | 16 +- website/static/js/search-grdm.js | 3 + website/templates/project/settings.mako | 2 +- .../ja/LC_MESSAGES/js_messages.po | 2 +- 24 files changed, 298 insertions(+), 69 deletions(-) diff --git a/addons/binderhub/static/node-cfg.js b/addons/binderhub/static/node-cfg.js index 930d552d9ea..d052c782ad7 100644 --- a/addons/binderhub/static/node-cfg.js +++ b/addons/binderhub/static/node-cfg.js @@ -99,6 +99,9 @@ function NodeSettings() { callback(); } }).fail(function(xhr, status, error) { + if (osfHelpers.handleErrorResponse(xhr) === false) { + return; + } Raven.captureMessage('Error while retrieving addon info', { extra: { url: url, diff --git a/admin/base/schemas/config-schema.json b/admin/base/schemas/config-schema.json index a5331236aa9..cdcdc7ef3c8 100644 --- a/admin/base/schemas/config-schema.json +++ b/admin/base/schemas/config-schema.json @@ -5,7 +5,8 @@ "type": "object", "properties": { "function_name": { - "type": "string" + "type": "string", + "pattern": "\\S+" }, "api_group": { "type": "array", @@ -14,17 +15,19 @@ "type": "object", "properties": { "api": { - "type": "string" + "type": "string", + "pattern": "\\S+" }, "method": { - "type": "string" + "type": "string", + "pattern": "\\S+" } }, "required": ["api", "method"] } } }, - "additionalProperties": false + "required": ["function_name", "api_group"] } }, "additionalProperties": false diff --git a/admin/base/schemas/service-access-control-setting-schema.json b/admin/base/schemas/service-access-control-setting-schema.json index 80cfd43bc1b..3ae0eafc84f 100644 --- a/admin/base/schemas/service-access-control-setting-schema.json +++ b/admin/base/schemas/service-access-control-setting-schema.json @@ -26,7 +26,8 @@ }, "project_limit_number": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 2147483647 }, "is_whitelist": { "type": "boolean" @@ -47,10 +48,8 @@ "is_whitelist", "function_codes" ] - }, - "additionalProperties": false + } } }, - "required": ["data"], - "additionalProperties": false + "required": ["data"] } diff --git a/admin/base/schemas/utils.py b/admin/base/schemas/utils.py index 278cd428086..ebe10f39764 100644 --- a/admin/base/schemas/utils.py +++ b/admin/base/schemas/utils.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +import jsonschema import os here = os.path.split(os.path.abspath(__file__))[0] @@ -8,3 +9,37 @@ def from_json(file_name): with open(os.path.join(here, file_name)) as f: return json.load(f) + + +def validate_json_schema(value, json_schema_name): + """ Validate JSON data with JSON schema """ + # Load JSON schema file + json_schema = from_json(json_schema_name) + # Validate data with the JSON schema + jsonschema.validate(value, json_schema) + + +def validate_config_schema(value, json_schema_name): + """ Validate the config JSON data with config JSON schema and additional logic""" + # Validate data with the JSON schema + validate_json_schema(value, json_schema_name) + # Additional validation for config JSON file + function_name_list = [] + api_group_list = [] + for json_key, json_value in value.items(): + function_name = json_value.get('function_name') + api_group = json_value.get('api_group', []) + function_name_list.append(function_name) + api_group_list.extend([(item.get('api'), item.get('method'),) for item in api_group]) + + # Check if 'function_name' is unique + is_function_name_list_unique = len(function_name_list) == len(set(function_name_list)) + if not is_function_name_list_unique: + # If 'function_name' is not unique, raise validation error + raise jsonschema.ValidationError('Config data is invalid: there are non-unique function_name') + + # Check if 'api_group' is unique + is_api_group_list_unique = len(api_group_list) == len(set(api_group_list)) + if not is_api_group_list_unique: + # If 'api_group' is not unique, raise validation error + raise jsonschema.ValidationError('Config data is invalid: there are non-unique api_group') diff --git a/admin/base/urls.py b/admin/base/urls.py index 426e7d5711f..e389ee75ce9 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -49,7 +49,7 @@ url(r'^institutional_storage_quota_control/', include('admin.institutional_storage_quota_control.urls', namespace='institutional_storage_quota_control')), url(r'^metadata/', include('admin.rdm_metadata.urls', namespace='metadata')), - url(r'^service_access_control_setting/', include('admin.service_access_control_setting.urls', namespace='service_access_control_setting')), + url(r'^service_access_control/', include('admin.service_access_control_setting.urls', namespace='service_access_control_setting')), ]), ), ] diff --git a/admin/service_access_control_setting/urls.py b/admin/service_access_control_setting/urls.py index 3c20f7509d6..82d7356d557 100644 --- a/admin/service_access_control_setting/urls.py +++ b/admin/service_access_control_setting/urls.py @@ -3,5 +3,5 @@ urlpatterns = [ url(r'^$', views.ServiceAccessControlSettingView.as_view(), name='list'), - url(r'^setting/$', views.ServiceAccessControlSettingCreateView.as_view(), name='create_setting'), + url(r'^setting$', views.ServiceAccessControlSettingCreateView.as_view(), name='create_setting'), ] diff --git a/admin/service_access_control_setting/views.py b/admin/service_access_control_setting/views.py index aa2504428af..0d45feb8b70 100644 --- a/admin/service_access_control_setting/views.py +++ b/admin/service_access_control_setting/views.py @@ -21,7 +21,7 @@ from osf.exceptions import ValidationError from osf.models import Function, Institution from osf.models.service_access_control_setting import ServiceAccessControlSetting -from admin.base.schemas.utils import from_json +from admin.base.schemas.utils import validate_json_schema, validate_config_schema from admin.base.settings import BASE_DIR from admin.rdm.utils import RdmPermissionMixin @@ -64,7 +64,7 @@ def get_queryset(self): institution_subquery = Institution.objects.filter(_id=OuterRef('institution_id')).values('name') # Create queryset queryset = ServiceAccessControlSetting.objects.filter( - functions__isnull=False, is_deleted=False + functions__is_deleted=False, is_deleted=False ).annotate( institution_name=Subquery(institution_subquery), function_codes=ArrayAgg('functions__function_code') ).order_by('institution_name', 'domain', 'is_ial2_or_aal2', 'user_domain') @@ -84,10 +84,8 @@ def get_context_data(self, **kwargs): # Load JSON config data with open(os.path.join(BASE_DIR, CONFIG_PATH), encoding='utf-8') as fp: config_data = json.load(fp, object_pairs_hook=OrderedDict) - # Load config data schema json file - function_config_schema = from_json(CONFIG_SCHEMA_FILE_NAME) # Validate config data with the JSON schema - jsonschema.validate(config_data, function_config_schema) + validate_config_schema(config_data, CONFIG_SCHEMA_FILE_NAME) except Exception: # Return no data return { @@ -152,16 +150,20 @@ def post(self, request): # Load setting data from uploaded JSON file file = self.parse_file(request.FILES['file']) setting_json = json.loads(file) - except ValueError as e: + except json.decoder.JSONDecodeError as e: + # Fail to decode JSON file, return HTTP 400 + logger.error(f'JSON file is invalid: {e}') + response_body = JSON_FILE_INVALID_RESPONSE + status_code = http_status.HTTP_400_BAD_REQUEST + raise e + except Exception as e: # Fail to load setting data, return HTTP 500 logger.error(f'Fail to load setting data with error {e}') raise e try: - # Load setting JSON schema file - setting_schema = from_json(SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME) # Validate setting data with the JSON schema - jsonschema.validate(setting_json, setting_schema) + validate_json_schema(setting_json, SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME) except (jsonschema.ValidationError, jsonschema.SchemaError) as e: logger.error(f'JSON file is invalid: {e}') response_body = JSON_FILE_INVALID_RESPONSE @@ -172,10 +174,8 @@ def post(self, request): # Load config data file with open(os.path.join(BASE_DIR, CONFIG_PATH), encoding='utf-8') as fp: function_config_json = json.load(fp) - # Load config data schema json file - function_config_schema = from_json(CONFIG_SCHEMA_FILE_NAME) # Validate config data with the JSON schema - jsonschema.validate(function_config_json, function_config_schema) + validate_config_schema(function_config_json, CONFIG_SCHEMA_FILE_NAME) except Exception as e: logger.error(f'Config data is invalid: {e}') response_body = CONFIG_DATA_INVALID_RESPONSE @@ -243,8 +243,9 @@ def post(self, request): # Bulk insert osf_service_access_control_setting ServiceAccessControlSetting.objects.bulk_create(service_access_control_settings) # Bulk insert osf_function - for each in function_settings: - each.service_access_control_setting_id = each.service_access_control_setting.id + for function_setting_item in function_settings: + # Assign now created osf_service_access_control_setting.id to osf_function.service_access_control_setting_id + function_setting_item.service_access_control_setting_id = function_setting_item.service_access_control_setting.id Function.objects.bulk_create(function_settings) except Exception as e: logger.error(f'Exception raised in the create setting transaction: {e}') diff --git a/admin/static/css/institutions.css b/admin/static/css/institutions.css index 0b8788cda24..39ccfedc5a2 100644 --- a/admin/static/css/institutions.css +++ b/admin/static/css/institutions.css @@ -125,8 +125,14 @@ input.apple-switch.checked:after { .fixed-table-cell { min-width: 100px; + word-wrap: break-word; + max-width: 200px; + white-space: normal !important; } .table-header-cell { background-color: #f9f9f9; + word-wrap: break-word; + max-width: 150px; + white-space: normal !important; } diff --git a/admin/static/js/service_access_control_setting/service-access-control-setting.js b/admin/static/js/service_access_control_setting/service-access-control-setting.js index e6bbdb7bc26..7ae384bba4a 100644 --- a/admin/static/js/service_access_control_setting/service-access-control-setting.js +++ b/admin/static/js/service_access_control_setting/service-access-control-setting.js @@ -20,6 +20,8 @@ $.ajaxSetup({ }); $('#upload-button').click(function() { + // Clear file data before trigger file event + $('#file-upload').val(''); // Trigger input[type='file'] click event $('#file-upload').click(); }); @@ -36,25 +38,28 @@ $('#file-upload').change(function() { var formData = new FormData(); formData.append('file', uploadFile); $.ajax({ - url: 'setting/', + url: 'setting', type: 'POST', data: formData, + // Set processData to false to avoid processing file data processData: false, + // Set contentType to false to avoid adding "Content-Type" header in "multipart/form-data" request contentType: false, success: function(json) { // Reload the page window.location.reload(); }, error: function(jqXHR) { - // Reset input[type='file'] value - $('#file-upload').val(''); var data = jqXHR.responseJSON; if (data && data['message']) { // If response has message, show that message $osf.growl('Error', _(data['message']), 'danger', 5000); - } else { - // Otherwise, show default error message 'A server error occurred. Please contact the administrator.' + } else if (jqXHR.status === 500) { + // If status is 500, show error message 'A server error occurred. Please contact the administrator.' $osf.growl('Error', 'A server error occurred. Please contact the administrator.', 'danger', 5000); + } else { + // Otherwise, show default error message + $osf.growl('Error', _('Some errors occurred'), 'danger', 5000); } } }); diff --git a/admin/templates/service_access_control_setting/list.html b/admin/templates/service_access_control_setting/list.html index e1eaf8b9ab1..d2b536294a0 100644 --- a/admin/templates/service_access_control_setting/list.html +++ b/admin/templates/service_access_control_setting/list.html @@ -40,7 +40,7 @@

    {% trans "Service Access Control Setting Management" %}

    {% trans "Project Limit Number" %} {% for function_code, function_name in column_data.items %} - {{ function_name }} + {{ function_name }} {% endfor %} @@ -113,7 +113,7 @@

    {% trans "Service Access Control Setting Management" %}

    {% else %} -

    {% trans "No results found" %}

    +

    {% trans "No data found" %}

    {% endif %} {% endblock content %} diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 3671a046141..da099920380 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -2968,6 +2968,9 @@ msgstr "" msgid "Service Access Control Setting Management" msgstr "" +msgid "No data found" +msgstr "" + msgid "Create setting" msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index d23139e66e3..37ad5e9a64a 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -2990,6 +2990,9 @@ msgstr "" msgid "Service Access Control Setting Management" msgstr "" +msgid "No data found" +msgstr "" + msgid "Create setting" msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index b5a4e44245f..727eb2fb275 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -3024,6 +3024,9 @@ msgstr "タイムスタンプ機能を選択" msgid "Service Access Control Setting Management" msgstr "サービスアクセス制御管理" +msgid "No data found" +msgstr "データがありません。" + msgid "Create setting" msgstr "設定作成" diff --git a/admin_tests/base/test_utils.py b/admin_tests/base/test_utils.py index 37738383542..8077a03608c 100644 --- a/admin_tests/base/test_utils.py +++ b/admin_tests/base/test_utils.py @@ -1,3 +1,4 @@ +import jsonschema from nose.tools import * # noqa: F403 import datetime as datetime import pytest @@ -10,7 +11,8 @@ from django.forms.models import model_to_dict from django.http import QueryDict -from admin.base.schemas.utils import from_json +from admin.base.schemas.utils import from_json, validate_json_schema, validate_config_schema +from admin.service_access_control_setting.views import SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME, CONFIG_SCHEMA_FILE_NAME from tests.base import AdminTestCase from osf_tests.factories import SubjectFactory, UserFactory, RegistrationFactory, PreprintFactory @@ -255,3 +257,154 @@ def test_from_json(self): def test_from_json__file_not_found(self): with pytest.raises(Exception): from_json('file-info-schema2.json') + + def test_validate_json_schema(self): + test_dict = { + 'data': [ + { + 'institution_id': 'test', + 'domain': 'test.com', + 'is_ial2_or_aal2': True, + 'user_domain': '@test.com', + 'project_limit_number': 10, + 'is_whitelist': False, + 'function_codes': ['function_001'] + } + ] + } + res = validate_json_schema(test_dict, SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME) + assert res is None + + def test_validate_json_schema__error(self): + # Schema file not found error + test_dict = { + 'data': [ + { + 'institution_id': 'test', + 'domain': 'test.com', + 'is_ial2_or_aal2': True, + 'user_domain': '@test.com', + 'project_limit_number': 10, + 'is_whitelist': False, + 'function_codes': ['function_001'] + } + ] + } + with assert_raises(FileNotFoundError): + validate_json_schema(test_dict, 'not-found.json') + + # JSON schema validation error + test_dict = { + 'data': [{}] + } + with assert_raises(jsonschema.ValidationError): + validate_json_schema(test_dict, SERVICE_ACCESS_CONTROL_SCHEMA_FILE_NAME) + + def test_validate_config_schema(self): + test_dict = { + 'function_001': { + 'function_name': 'test', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + } + ] + } + } + res = validate_config_schema(test_dict, CONFIG_SCHEMA_FILE_NAME) + assert res is None + + def test_validate_config_schema__validate_error(self): + # Schema file not found error + test_dict = { + 'function_001': { + 'function_name': 'test', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + } + ] + } + } + with assert_raises(FileNotFoundError): + validate_config_schema(test_dict, 'not-found.json') + + # JSON schema validation error + test_dict = { + 'function_001': { + 'function_name': '', + 'api_group': [{}] + } + } + with assert_raises(jsonschema.ValidationError): + validate_config_schema(test_dict, CONFIG_SCHEMA_FILE_NAME) + + def test_validate_config_schema__function_name_not_unique(self): + test_dict = { + 'function_001': { + 'function_name': 'test', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + } + ] + }, + 'function_002': { + 'function_name': 'test', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'POST', + } + ] + } + } + with assert_raises(jsonschema.ValidationError): + validate_config_schema(test_dict, CONFIG_SCHEMA_FILE_NAME) + + def test_validate_config_schema__api_group_not_unique(self): + # Duplicate api_group in the same function code + test_dict = { + 'function_001': { + 'function_name': 'test', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + }, + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + } + ] + } + } + with assert_raises(jsonschema.ValidationError): + validate_config_schema(test_dict, CONFIG_SCHEMA_FILE_NAME) + + # Duplicate api_group in different function code + test_dict = { + 'function_001': { + 'function_name': 'test', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + } + ] + }, + 'function_002': { + 'function_name': 'test_2', + 'api_group': [ + { + 'api': r'^/v2/nodes/?$', + 'method': 'GET', + } + ] + } + } + with assert_raises(jsonschema.ValidationError): + validate_config_schema(test_dict, CONFIG_SCHEMA_FILE_NAME) diff --git a/admin_tests/service_access_control_setting/test_views.py b/admin_tests/service_access_control_setting/test_views.py index f543de7f528..ca99645ee1b 100644 --- a/admin_tests/service_access_control_setting/test_views.py +++ b/admin_tests/service_access_control_setting/test_views.py @@ -108,12 +108,12 @@ def test_get_context_data__read_config_file_error(self): self.request.user.is_superuser = True self.request.user.affiliated_institutions.clear() with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: - with mock.patch('admin.service_access_control_setting.views.from_json') as mock_from_json: - mock_from_json.side_effect = ValueError('test fail to load file') + with mock.patch('admin.service_access_control_setting.views.validate_config_schema') as mock_validate_config_schema: + mock_validate_config_schema.side_effect = ValueError('test fail to load file') self.view.object_list = self.view.get_queryset() res = self.view.get_context_data() mock_open_file.assert_called() - mock_from_json.assert_called() + mock_validate_config_schema.assert_called() nt.assert_is_not_none(res) nt.assert_equal(res['column_data'], {}) nt.assert_equal(res['row_data'], []) @@ -186,10 +186,20 @@ def test_post(self): nt.assert_equal(res.status_code, http_status.HTTP_200_OK) nt.assert_equal(res.content, b'{}') - def test_post__read_upload_file_error(self): + def test_post__read_upload_file_not_json(self): self.request.user.is_superuser = True self.request.user.affiliated_institutions.clear() self.request.FILES['file'] = SimpleUploadedFile('text.txt', b'text') + with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: + res = self.view.post(self.request) + mock_open_file.assert_not_called() + nt.assert_equal(res.status_code, http_status.HTTP_400_BAD_REQUEST) + nt.assert_equal(res.content, b'{"message": "JSON file is invalid."}') + + def test_post__read_upload_file_error(self): + self.request.user.is_superuser = True + self.request.user.affiliated_institutions.clear() + self.request.FILES['file'] = None with mock.patch('admin.service_access_control_setting.views.open', mock.mock_open(read_data=self.mock_config_json)) as mock_open_file: with nt.assert_raises(Exception): self.view.post(self.request) diff --git a/framework/function_control/handlers.py b/framework/function_control/handlers.py index e53f027d5fe..09625b78b31 100644 --- a/framework/function_control/handlers.py +++ b/framework/function_control/handlers.py @@ -2,13 +2,12 @@ import logging import json import os -import jsonschema import re from django.http import JsonResponse from flask import request, abort, Response from rest_framework import status as http_status -from admin.base.schemas.utils import from_json +from admin.base.schemas.utils import validate_config_schema from admin.base.settings import BASE_DIR from framework.flask import redirect @@ -27,10 +26,8 @@ def check_api_service_access(request_url_path, request_method, user): # Load config data file with open(os.path.join(BASE_DIR, CONFIG_PATH), encoding='utf-8') as fp: function_config_json = json.load(fp) - # Load config data schema json file - schema = from_json(CONFIG_SCHEMA_FILE_NAME) # Validate config data with the JSON schema - jsonschema.validate(function_config_json, schema) + validate_config_schema(function_config_json, CONFIG_SCHEMA_FILE_NAME) except Exception as e: # Fail to load or validate config data, return None logger.warning(f'Failed to load config schema with exception {e}') diff --git a/website/static/js/contribAdder.js b/website/static/js/contribAdder.js index 51ce58fd1f5..5766ebfeb95 100644 --- a/website/static/js/contribAdder.js +++ b/website/static/js/contribAdder.js @@ -327,10 +327,10 @@ AddContributorViewModel = oop.extend(Paginator, { validateInviteForm: function () { var self = this; // Make sure Full Name is not blank - if (!self.inviteName().trim().length) { + if (!self.inviteName() || !self.inviteName().trim().length) { return _('Full Name is required.'); } - if (self.inviteEmail() && !$osf.isEmail(self.inviteEmail().replace(/^\s+|\s+$/g, ''))) { + if (!self.inviteEmail() || !$osf.isEmail(self.inviteEmail().replace(/^\s+|\s+$/g, ''))) { return _('Not a valid email address.'); } // Make sure that entered email is not already in selection @@ -510,15 +510,15 @@ AddContributorViewModel = oop.extend(Paginator, { onInviteError: function (xhr) { var self = this; if (xhr.status === 403) { - var continueHandle = $osf.handleErrorResponse(xhr); - if (continueHandle === false) { + if ($osf.handleErrorResponse(xhr) === false) { return; } self.inviteError(_('You do not have permission to operate a project.')); + } else { + var response = JSON.parse(xhr.responseText); + // Update error message + self.inviteError(response.message); } - var response = JSON.parse(xhr.responseText); - // Update error message - self.inviteError(response.message); self.canSubmit(true); }, hasChildren: function() { diff --git a/website/static/js/contribRemover.js b/website/static/js/contribRemover.js index f5571d52fd4..0830d0dc595 100644 --- a/website/static/js/contribRemover.js +++ b/website/static/js/contribRemover.js @@ -244,7 +244,7 @@ var RemoveContributorViewModel = oop.extend(Paginator, { } }); self.clear(); - window.location.reload(); + $('.modal').modal('hide'); }); }, deleteAllNodes: function() { diff --git a/website/static/js/osfHelpers.js b/website/static/js/osfHelpers.js index 8c6d103c6b2..99ac0c0b546 100644 --- a/website/static/js/osfHelpers.js +++ b/website/static/js/osfHelpers.js @@ -102,7 +102,12 @@ var ajaxJSON = function(method, url, options) { } $.extend(true, ajaxFields, opts.fields); - return $.ajax(ajaxFields); + return $.ajax(ajaxFields).fail(function(xhr) { + if (xhr.status === 403 && handleErrorResponse(xhr) === false) { + // If response status is 403, check if the response requests to redirect to 403 page + return; + } + }); }; /** @@ -1071,15 +1076,12 @@ function handleErrorResponse(xhr) { var data = xhr.responseJSON; if (data && data.errors && data.errors.length > 0) { var error_message = data.errors[0]; - if (error_message.type && error_message.type === 1) { - // Continue handle on client - return true; + if (error_message.type != null && error_message.type === 0) { + // Redirect to HTTP 403 + window.location.href = '/403'; + return false; } } - - // Redirect to HTTP 403 - window.location.href = '/403'; - return false; } // Continue handle on client return true; diff --git a/website/static/js/pages/project-addons-page.js b/website/static/js/pages/project-addons-page.js index e6e049f591a..869b29e9700 100644 --- a/website/static/js/pages/project-addons-page.js +++ b/website/static/js/pages/project-addons-page.js @@ -11,7 +11,10 @@ var changeAddonSettingsSuccess = function () { location.reload(); }; -var changeAddonSettingsFailure = function () { +var changeAddonSettingsFailure = function (xhr) { + if ($osf.handleErrorResponse(xhr) === false) { + return; + } var msg = _('Sorry, we had trouble saving your settings. If this persists please contact ') + '
    rdm_support@nii.ac.jp'; $osf.growl('Failure', msg, 'danger'); }; diff --git a/website/static/js/pages/project-dashboard-page.js b/website/static/js/pages/project-dashboard-page.js index 88e9d5a36f7..6ad753329ba 100644 --- a/website/static/js/pages/project-dashboard-page.js +++ b/website/static/js/pages/project-dashboard-page.js @@ -670,8 +670,7 @@ $(document).ready(function () { request.fail(function(xhr, textStatus, error) { window.contextVars.node.tags.splice(window.contextVars.node.tags.indexOf(tag),1); if (xhr.status === 403) { - var continueHandle = $osf.handleErrorResponse(xhr); - if (continueHandle === false) { + if ($osf.handleErrorResponse(xhr) === false) { return; } $osf.growl('Error', _('You do not have permission to operate a project.')); @@ -688,13 +687,13 @@ $(document).ready(function () { if (!tag) { return false; } - window.contextVars.node.tags.splice(window.contextVars.node.tags.indexOf(tag),1); + var newTags = $.extend(true, [], window.contextVars.node.tags); var payload = { data: { type: nodeType, id: window.contextVars.node.id, attributes: { - tags: window.contextVars.node.tags + tags: newTags.splice(newTags.indexOf(tag),1) } } }; @@ -706,21 +705,22 @@ $(document).ready(function () { data: payload, isCors: true } - ); + ).done(function (){ + window.contextVars.node.tags.splice(window.contextVars.node.tags.indexOf(tag),1); + }); request.fail(function(xhr, textStatus, error) { - window.contextVars.node.tags.push(tag); // Suppress "tag not found" errors, as the end result is what the user wanted (tag is gone)- eg could be because two people were working at same time if (xhr.status !== 409) { if (xhr.status === 403) { - var continueHandle = $osf.handleErrorResponse(xhr); - if (continueHandle === false) { + if ($osf.handleErrorResponse(xhr) === false) { return; } $osf.growl('Error', _('You do not have permission to operate a project.')); } else { $osf.growl('Error', _('Could not remove tag')); } + $('#node-tags').importTags(window.contextVars.node.tags.join(',')); Raven.captureMessage(_('Failed to remove tag'), { extra: { tag: tag, url: tagsApiUrl, textStatus: textStatus, error: error diff --git a/website/static/js/search-grdm.js b/website/static/js/search-grdm.js index 5d9c479f0db..3679143da02 100644 --- a/website/static/js/search-grdm.js +++ b/website/static/js/search-grdm.js @@ -620,6 +620,9 @@ var ViewModel = function(params) { self.searching(false); }).fail(function(response){ + if ($osf.handleErrorResponse(response) === false) { + return; + } self.totalResults(0); self.currentPage(0); self.results([]); diff --git a/website/templates/project/settings.mako b/website/templates/project/settings.mako index 0ee4c3e2ae9..c45ecfc99cd 100644 --- a/website/templates/project/settings.mako +++ b/website/templates/project/settings.mako @@ -131,7 +131,7 @@ % endif -
    diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index 63d61ff35db..8775702a4f2 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -3746,7 +3746,7 @@ msgstr "不明なエラーがありました。 後でもう一度やり直し #: website/static/js/addProjectPlugin.js:58 msgid "You do not have permission to operate a project." -msgstr "プロジェクトを作成する権限がありません。" +msgstr "プロジェクトに対する操作権限がありません。" #: website/static/js/addProjectPlugin.js:59 msgid "The new project cannot be created due to the created project number is greater than or equal the project number can create."