diff --git a/SAS/TMSS/src/tmss/tmssapp/models/specification.py b/SAS/TMSS/src/tmss/tmssapp/models/specification.py index b3629f35cfd18d93ccf77af17911b7f9928271cd..11f95ce9340917125e8222053b55de2fbb946a38 100644 --- a/SAS/TMSS/src/tmss/tmssapp/models/specification.py +++ b/SAS/TMSS/src/tmss/tmssapp/models/specification.py @@ -21,7 +21,6 @@ from django.urls import reverse as revese_url from collections import Counter from django.utils.functional import cached_property - # # Common # @@ -843,6 +842,11 @@ class TaskDraft(NamedCommon): pass return self.relative_start_time + @cached_property + def project(self) -> Project: + '''return the related project of this task + ''' + return self.scheduling_unit_draft.scheduling_set.project # JK, 28/07/20: After discussion with Sander, we probably only want the # - duration on the scheduling_unit draft (based on relative start/stop times) diff --git a/SAS/TMSS/src/tmss/tmssapp/viewsets/CMakeLists.txt b/SAS/TMSS/src/tmss/tmssapp/viewsets/CMakeLists.txt index fc0325a523508e371b2456d96b3467274dae748d..8f21d3c956f637dd42e50f3676699db3b5bd8139 100644 --- a/SAS/TMSS/src/tmss/tmssapp/viewsets/CMakeLists.txt +++ b/SAS/TMSS/src/tmss/tmssapp/viewsets/CMakeLists.txt @@ -6,6 +6,7 @@ set(_py_files lofar_viewset.py specification.py scheduling.py + permissions.py ) python_install(${_py_files} diff --git a/SAS/TMSS/src/tmss/tmssapp/viewsets/permissions.py b/SAS/TMSS/src/tmss/tmssapp/viewsets/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..43062ba2f172f632076e7b1d3f5b315b786b5352 --- /dev/null +++ b/SAS/TMSS/src/tmss/tmssapp/viewsets/permissions.py @@ -0,0 +1,101 @@ +""" +This file contains permissions and filters that are used in the viewsets +""" + +from rest_framework import permissions, filters +from lofar.sas.tmss.tmss.tmssapp import models +# +# Permissions +# +# Note: These prevent accessing the object directly, and return a nice permission error. +# However, object permissions are not considered on a listing by default, so make sure to either +# A. apply a filter to prevent object without permission to be included in the list with full details, or +# B. customize get_queryset on the view to call check_object_permissions. + +class IsProjectMember(permissions.DjangoObjectPermissions): + """ + Object-level permission to only allow users of the related project to access it. + Note: instance must have a project attribute. + + Define a filter_project_roles attribute on the view to further restrict access to a list of specific project roles. + (Some user role background can be found here https://support.astron.nl/confluence/display/TMSS/User+roles). + """ + def has_object_permission(self, request, view, obj): + + # determine which roles are allowed to access this object + if hasattr(view, 'filter_project_roles'): + filter_project_roles = view.filter_project_roles + else: + # allow all roles by default, if nothing was specified in the view + filter_project_roles = ['PI', 'CO-I', 'Contact Author', 'Shared support user', 'Friend of Project', 'Friend of Project (Primary)'] + + # determine the projects that the users has one of the allowed roles for + # todo: the following user_project_roles are fake data and will be eventually defined by the OIDC federation, most likely to be read from request.user.??? + user_project_roles = ({'project': 'high', 'project_role': 'PI'}, + {'project': 'low', 'project_role': 'Friend of Project'}) + + permitted_projects = [models.Project.objects.get(name=project_role['project']) for project_role in user_project_roles if project_role['project_role'] in filter_project_roles] + + # check whether the related project of this object is one that the user has permitted to see + return obj.project in permitted_projects + + +class IsProjectMemberOrReadOnly(IsProjectMember): + """ + Object-level permission to only allow users of the related project to modify it. + Note: instance must have a project attribute. + + Define a filter_project_roles attribute on the view to further restrict access to a list of specific project roles. + (Some user role background can be found here https://support.astron.nl/confluence/display/TMSS/User+roles). + """ + def has_object_permission(self, request, view, obj): + + # generally allow reading, otherwise check permissions: + if request.method in permissions.SAFE_METHODS: + return True + else: + return super().has_object_permission(request, view, obj) + + +# +# Custom Filters +# + +class IsProjectMemberFilterBackend(filters.BaseFilterBackend): + """ + Filter that only allows users to see objects that are related to their projects. + + This only returns objects that belong to a project for which the requesting user has the required project role. + Define a filter_project_roles attribute on the view to further restrict access to a list of specific project roles. + (Some user role background can be found here https://support.astron.nl/confluence/display/TMSS/User+roles). + """ + def filter_queryset(self, request, queryset, view): + + # Note that while filtering excludes certain items from the list view as expected, it also means that no + # permission errors are raised when we do not restrict filtering to list view, and 'not found' is returned + # instead, which might be confusing. So we want to explicitly only filter listings here: + if view.action != 'list': + return queryset + + # determine which roles are allowed to access this object + if hasattr(view, 'filter_project_roles'): + filter_project_roles = view.filter_project_roles + else: + # allow all roles by default, if nothing was specified in the view + filter_project_roles = ['PI', 'CO-I', 'Contact Author', 'Shared support user', 'Friend of Project', 'Friend of Project (Primary)'] + + # determine the projects that the users has one of the allowed roles for + # todo: the following user_project_roles are fake data and will be eventually defined by the OIDC federation, most likely to be read from request.user.??? + user_project_roles = ({'project': 'high', 'project_role': 'PI'}, + {'project': 'low', 'project_role': 'Friend of Project'}) + + permitted_projects = [models.Project.objects.get(name=project_role['project']) for project_role in user_project_roles if project_role['project_role'] in filter_project_roles] + + # Unfortunately, we cannot simply filter in SQL for model properties via queryset.filter(project__in=projects). + # I'm not sure how we can achieve a generic way to look up the related project in SQL, since it needs to be + # resolved differently for different models. For now, fetch all and filter down the full set: + permitted_fetched_objects = list(filter(lambda x: x.project in permitted_projects, queryset.all())) + # we could return the list of objects, which seems to work if you don't touch the get_queryset. + # But are supposed to return a queryset instead, so we make a new one, even though we fetched already. + # I don't know, there must be a better way... + return queryset.filter(id__in=[o.id for o in permitted_fetched_objects]) diff --git a/SAS/TMSS/src/tmss/tmssapp/viewsets/specification.py b/SAS/TMSS/src/tmss/tmssapp/viewsets/specification.py index 64c4e9e588e228a509c12be4f66a687b132ae096..cc3c72586cb58e66f28048b2aa19bdd9b3ff25a2 100644 --- a/SAS/TMSS/src/tmss/tmssapp/viewsets/specification.py +++ b/SAS/TMSS/src/tmss/tmssapp/viewsets/specification.py @@ -19,6 +19,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg.openapi import Parameter from lofar.sas.tmss.tmss.tmssapp.viewsets.lofar_viewset import LOFARViewSet, LOFARNestedViewSet, AbstractTemplateViewSet, LOFARCopyViewSet +from lofar.sas.tmss.tmss.tmssapp.viewsets.permissions import IsProjectMemberFilterBackend, IsProjectMember, IsProjectMemberOrReadOnly from lofar.sas.tmss.tmss.tmssapp import models from lofar.sas.tmss.tmss.tmssapp import serializers from django.http import JsonResponse @@ -682,6 +683,9 @@ class SchedulingUnitBlueprintNestedViewSet(LOFARNestedViewSet): class TaskDraftViewSet(LOFARViewSet): queryset = models.TaskDraft.objects.all() serializer_class = serializers.TaskDraftSerializer + permission_classes = (IsProjectMember,) + filter_backends = (IsProjectMemberFilterBackend,) + filter_project_roles = ['PI'] # prefetch all reverse related references from other models on their related_name to avoid a ton of duplicate queries queryset = queryset.prefetch_related('first_scheduling_relation') \ @@ -699,6 +703,19 @@ class TaskDraftViewSet(LOFARViewSet): queryset = queryset.select_related('copies') \ .select_related('copy_reason') + # do not permit listing if the queryset contains objects that the user has not permission for. + # Note: this is not required if we apply correct filtering, but it's a nice check that we do filter correctly. + # Do not check on other actions, as the queryset might contain items that will be filtered later. + # todo: see if there is something like a get_filtered_queryset that always only includes what will be returned + # to the user, so we can always check object permissions on everything. + def get_queryset(self): + qs = super().get_queryset() + if self.action == 'list': + qs = self.filter_queryset(qs) + for obj in qs: + self.check_object_permissions(self.request, obj) + return qs + @swagger_auto_schema(responses={201: 'The created task blueprint, see Location in Response header', 403: 'forbidden'}, operation_description="Carve this draft task specification in stone, and make an (uneditable) blueprint out of it.")