From 678da04154f93d6754be72477ea102ee8d97cd70 Mon Sep 17 00:00:00 2001 From: Paolo Tormene Date: Fri, 16 Oct 2026 16:49:41 +0200 Subject: [PATCH 1/4] Implement a Django permission for sharing and unsharing owned jobs --- openquake/server/authorization/__init__.py | 0 openquake/server/authorization/apps.py | 5 +++ ...01_create_can_share_own_jobs_permission.py | 33 ++++++++++++++++ .../authorization/migrations/__init__.py | 0 openquake/server/settings.py | 1 + .../server/tests/test_restricted_mode.py | 39 +++++++++++++++++++ openquake/server/views.py | 3 +- 7 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 openquake/server/authorization/__init__.py create mode 100644 openquake/server/authorization/apps.py create mode 100644 openquake/server/authorization/migrations/0001_create_can_share_own_jobs_permission.py create mode 100644 openquake/server/authorization/migrations/__init__.py diff --git a/openquake/server/authorization/__init__.py b/openquake/server/authorization/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openquake/server/authorization/apps.py b/openquake/server/authorization/apps.py new file mode 100644 index 000000000000..3abb59786e75 --- /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 000000000000..145861e04b21 --- /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/__init__.py b/openquake/server/authorization/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openquake/server/settings.py b/openquake/server/settings.py index 735457802cd2..bad3a1ed3cf8 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/tests/test_restricted_mode.py b/openquake/server/tests/test_restricted_mode.py index f17b619138f8..9fd0032ca6f5 100644 --- a/openquake/server/tests/test_restricted_mode.py +++ b/openquake/server/tests/test_restricted_mode.py @@ -176,6 +176,45 @@ 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) + for job in (own_job, other_job): + db("UPDATE job SET ?D WHERE id=?x", + {'status': 'complete', 'is_running': 0}, 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, 'complete') + + 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 b932b1671842..9d7b1b8a7fa1 100644 --- a/openquake/server/views.py +++ b/openquake/server/views.py @@ -627,7 +627,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) From 4f5821a3f1ec35328bf63a2b059f564cb5539820 Mon Sep 17 00:00:00 2001 From: Paolo Tormene Date: Fri, 16 Oct 2026 17:03:57 +0200 Subject: [PATCH 2/4] Register can_view_exposure through the authorization app as well, decoupling permission provisioning from UserProfile --- ...2_register_can_view_exposure_permission.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 openquake/server/authorization/migrations/0002_register_can_view_exposure_permission.py 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 000000000000..07d8b3ee1351 --- /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), + ] From 7fbc4268993a51701b522de81baa24721092f1cc Mon Sep 17 00:00:00 2001 From: Paolo Tormene Date: Mon, 19 Oct 2026 10:20:21 +0200 Subject: [PATCH 3/4] Show sharing controls to authorized job owners --- openquake/server/templates/engine/index.html | 12 +++++++----- openquake/server/tests/test_restricted_mode.py | 13 +++++++++---- openquake/server/views.py | 12 ++++++++++-- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/openquake/server/templates/engine/index.html b/openquake/server/templates/engine/index.html index b1ae13d206ec..a27557ad9776 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 9fd0032ca6f5..4c1d811a5851 100644 --- a/openquake/server/tests/test_restricted_mode.py +++ b/openquake/server/tests/test_restricted_mode.py @@ -196,9 +196,10 @@ def test_group_permission_allows_low_level_users_to_share_own_jobs(self): 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) - for job in (own_job, other_job): - db("UPDATE job SET ?D WHERE id=?x", - {'status': 'complete', 'is_running': 0}, job.calc_id) + 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') @@ -209,7 +210,11 @@ def test_group_permission_allows_low_level_users_to_share_own_jobs(self): 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, 'complete') + 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) diff --git a/openquake/server/views.py b/openquake/server/views.py index 9d7b1b8a7fa1..36ca6ae0ec50 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)) From a808e33465508e1dd1a59876e0cb372ce62a4f44 Mon Sep 17 00:00:00 2001 From: Paolo Tormene Date: Mon, 19 Oct 2026 10:35:56 +0200 Subject: [PATCH 4/4] Add a space [ci skip] --- openquake/calculators/country_impact_report_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openquake/calculators/country_impact_report_builder.py b/openquake/calculators/country_impact_report_builder.py index 1487ac207f3e..4913de91aee0 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: