diff --git a/openquake/calculators/country_impact_report_builder.py b/openquake/calculators/country_impact_report_builder.py index 1487ac207f3..4913de91aee 100644 --- a/openquake/calculators/country_impact_report_builder.py +++ b/openquake/calculators/country_impact_report_builder.py @@ -569,7 +569,7 @@ def _build_left_bundle(self, summary_table, body_left_style, title_style): exposed_value_txt = ( f'The exposed value refers to the assets and population located' - f' within a {self.maximum_distance}km radius of the epicentre.') + f' within a {self.maximum_distance} km radius of the epicentre.') left_bundle.append( self.Paragraph(exposed_value_txt, body_left_style)) if self.no_uncertainty: diff --git a/openquake/server/authorization/__init__.py b/openquake/server/authorization/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openquake/server/authorization/apps.py b/openquake/server/authorization/apps.py new file mode 100644 index 00000000000..3abb59786e7 --- /dev/null +++ b/openquake/server/authorization/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AuthorizationConfig(AppConfig): + name = 'openquake.server.authorization' diff --git a/openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py b/openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py new file mode 100644 index 00000000000..145861e04b2 --- /dev/null +++ b/openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py @@ -0,0 +1,33 @@ +from django.db import migrations + + +def create_permission(apps, schema_editor): + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + content_type = ContentType.objects.get_for_model(Permission) + Permission.objects.get_or_create( + codename='can_share_own_jobs', + name='Can share own jobs', + content_type=content_type, + ) + + +def delete_permission(apps, schema_editor): + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + content_type = ContentType.objects.get_for_model(Permission) + Permission.objects.filter( + codename='can_share_own_jobs', content_type=content_type).delete() + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.RunPython(create_permission, delete_permission), + ] diff --git a/openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py b/openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py new file mode 100644 index 00000000000..07d8b3ee135 --- /dev/null +++ b/openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py @@ -0,0 +1,24 @@ +from django.db import migrations + + +def create_permission(apps, schema_editor): + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + content_type = ContentType.objects.get_for_model(Permission) + Permission.objects.get_or_create( + codename='can_view_exposure', + name='Can view exposure', + content_type=content_type, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('authorization', '0001_create_can_share_own_jobs_permission'), + ] + + # The legacy UserProfile migration also creates this permission; don't + # remove it when rolling back this migration. + operations = [ + migrations.RunPython(create_permission, migrations.RunPython.noop), + ] diff --git a/openquake/server/authorization/migrations/__init__.py b/openquake/server/authorization/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/openquake/server/settings.py b/openquake/server/settings.py index 735457802cd..bad3a1ed3cf 100644 --- a/openquake/server/settings.py +++ b/openquake/server/settings.py @@ -339,6 +339,7 @@ def filter(self, record): TEMPLATES[0]['OPTIONS']['context_processors'] = CONTEXT_PROCESSORS # add installed_apps for cookie-consent for app in ('django.contrib.auth', 'django.contrib.contenttypes', + 'openquake.server.authorization', 'openquake.server.user_profile', 'cookie_consent',): if app not in INSTALLED_APPS: INSTALLED_APPS += (app,) diff --git a/openquake/server/templates/engine/index.html b/openquake/server/templates/engine/index.html index b1ae13d206e..a27557ad977 100644 --- a/openquake/server/templates/engine/index.html +++ b/openquake/server/templates/engine/index.html @@ -93,11 +93,13 @@

List of calculations{% if application_mode != 'AELO' %}{% if server_name %} {% endif %} <% if (calc.get('status') == 'complete' || calc.get('status') == 'shared') { %> - {% if application_mode != 'READ_ONLY' and user_level >= 2 and lockdown %} - <% if (calc.get('status') == 'complete') { %> - Share - <% } else if (calc.get('status') == 'shared') { %> - Unshare + {% if application_mode != 'READ_ONLY' and lockdown %} + <% if (calc.get('can_manage_sharing')) { %> + <% if (calc.get('status') == 'complete') { %> + Share + <% } else if (calc.get('status') == 'shared') { %> + Unshare + <% } %> <% } %> {% endif %} diff --git a/openquake/server/tests/test_restricted_mode.py b/openquake/server/tests/test_restricted_mode.py index f17b619138f..4c1d811a585 100644 --- a/openquake/server/tests/test_restricted_mode.py +++ b/openquake/server/tests/test_restricted_mode.py @@ -176,6 +176,50 @@ def test_level_2_can_only_share_own_jobs(self): {'status': 'complete'}, job.calc_id) self.remove_calc(job.calc_id) + def test_group_permission_allows_low_level_users_to_share_own_jobs(self): + from django.contrib.auth.models import Group, Permission + from django.contrib.contenttypes.models import ContentType + + content_type = ContentType.objects.get( + app_label='auth', model='permission') + permission, _ = Permission.objects.get_or_create( + codename='can_share_own_jobs', name='Can share own jobs', + content_type=content_type) + group, _ = Group.objects.get_or_create( + name='test-can-share-own-jobs') + group.permissions.add(permission) + self.user1.groups.add(group) + + own_dic = dict(calculation_mode='event_based', + description='test_group_share_own_job') + other_dic = dict(calculation_mode='event_based', + description='test_group_share_other_job') + [own_job] = create_jobs([own_dic], user_name=self.user1.username) + [other_job] = create_jobs([other_dic], user_name=self.user2.username) + db("UPDATE job SET ?D WHERE id=?x", + {'status': 'complete', 'is_running': 0}, own_job.calc_id) + db("UPDATE job SET ?D WHERE id=?x", + {'status': 'shared', 'is_running': 0}, other_job.calc_id) + + self.c.login(username=self.user1.username, password=self.password1) + ret = self.post(f'{own_job.calc_id}/share') + self.assertEqual(ret.status_code, 200) + ret = self.post(f'{own_job.calc_id}/unshare') + self.assertEqual(ret.status_code, 200) + + ret = self.post(f'{other_job.calc_id}/share') + self.assertEqual(ret.status_code, 403) + self.assertEqual(db('SELECT status FROM job WHERE id=?x', + other_job.calc_id)[0].status, 'shared') + ret = self.get('list', include_shared=1) + listed = {calc['id']: calc for calc in ret.json()} + self.assertTrue(listed[own_job.calc_id]['can_manage_sharing']) + self.assertFalse(listed[other_job.calc_id]['can_manage_sharing']) + + self.remove_calc(own_job.calc_id) + self.c.login(username=self.user2.username, password=self.password2) + self.remove_calc(other_job.calc_id) + def test_superuser_can_share_other_users_jobs(self): job_dic = dict(calculation_mode='event_based', description='test_superuser_share_job') diff --git a/openquake/server/views.py b/openquake/server/views.py index b932b167184..36ca6ae0ec5 100644 --- a/openquake/server/views.py +++ b/openquake/server/views.py @@ -553,16 +553,23 @@ def calc_list(request, id=None): utils.get_valid_users(request), not utils.is_superuser(request), id) response_data = [] - username = psutil.Process(os.getpid()).username() + username = utils.get_username(request) + superuser = utils.is_superuser(request) + can_share_own_jobs = ( + get_user_level(request) >= 2 or + request.user.has_perm('auth.can_share_own_jobs')) + process_username = psutil.Process(os.getpid()).username() for (hc_id, owner, status, calculation_mode, is_running, desc, pid, parent_id, size_mb, host, start_time, relevant, tags) in calc_data: + can_manage_sharing = superuser or ( + owner == username and can_share_own_jobs) if settings.DISPLAY_USER_HOST and host: owner += '@' + host.split('.')[0] url = urljoin(base_url, 'v1/calc/%d' % hc_id) abortable = False if is_running: try: - if psutil.Process(pid).username() == username: + if psutil.Process(pid).username() == process_username: abortable = True except psutil.NoSuchProcess: pass @@ -573,6 +580,7 @@ def calc_list(request, id=None): dict(id=hc_id, owner=owner, calculation_mode=calculation_mode, status=status, is_running=bool(is_running), description=desc, url=url, + can_manage_sharing=can_manage_sharing, parent_id=parent_id, abortable=abortable, size_mb=size_mb, start_time=start_time_str, relevant=relevant, tags=tags)) @@ -627,7 +635,8 @@ def calc_remove(request, calc_id): def share_job(request, calc_id, share): user_level = get_user_level(request) superuser = utils.is_superuser(request) - if user_level < 2 and not superuser: + if (user_level < 2 and not superuser and + not request.user.has_perm('auth.can_share_own_jobs')): return HttpResponseForbidden() try: job = logs.dbcmd('get_job', calc_id)