From ecf375555c99392c62a698fa01cfadccacfebea0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste <bleme@pm.me> Date: Wed, 21 Oct 2020 15:22:35 +0200 Subject: [PATCH 1/8] major: Extends queryset to avoid post filtering on view permission --- README.md | 2 + djangoldp/models.py | 2 +- djangoldp/permissions.py | 25 +++++++++++- djangoldp/serializers.py | 28 +++++++------ djangoldp/tests/models.py | 12 ++++++ djangoldp/tests/permissions.py | 35 +++++++++++++++++ djangoldp/tests/tests_guardian.py | 42 ++++++++++++++++++-- djangoldp/tests/tests_user_permissions.py | 1 + djangoldp/views.py | 48 ++++++++++++++--------- 9 files changed, 160 insertions(+), 35 deletions(-) create mode 100644 djangoldp/tests/permissions.py diff --git a/README.md b/README.md index cf2760d5..932ae167 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,8 @@ This allows you to add permissions for anonymous, logged in user, author ... in By default `LDPPermissions` is used. Specific permissin classes can be developed to fit special needs. +The method `prefilter_query_s_set()` should be override and must add a filter to the query_set removing object without at least the `view` permission + ## anonymous_perms, user_perms, owner_perms Those allow you to set permissions from your model's meta. diff --git a/djangoldp/models.py b/djangoldp/models.py index 8976d449..7e2a485b 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -240,7 +240,7 @@ class Model(models.Model): return None @classonlymethod - def get_permission_classes(cls, related_model, default_permissions_classes): + def get_permission_classes(cls, related_model, default_permissions_classes) -> LDPPermissions: '''returns the permission_classes set in the models Meta class''' return cls.get_meta(related_model, 'permission_classes', default_permissions_classes) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index c8f71aa8..70e5c1b7 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -2,7 +2,9 @@ import time from django.conf import settings from django.contrib.auth.models import _user_get_all_permissions from django.core.exceptions import PermissionDenied +from django.db.models import QuerySet from django.db.models.base import ModelBase +from guardian.shortcuts import get_objects_for_user from rest_framework.permissions import DjangoObjectPermissions @@ -33,13 +35,32 @@ class LDPPermissions(DjangoObjectPermissions): if time.time() - cls.perms_cache['time'] > 5: cls.invalidate_cache() + def prefilter_query_set(self, query_set, request, view, model) -> QuerySet: + """ + Must filter object that user doesn't have at least the `view` permission + Should reflect user_permissions + :param query_set: the base QuerySet + :param request: the request + :param view: the view + :param model: the model + :return: the filtered QuerySet + """ + if self.has_object_permission(request, view, model): + value = query_set + else: + try: + permissions_needed = self.get_permissions(request.method, model) + value = get_objects_for_user(request.user, permissions_needed, query_set) + except: + value = [] + return query_set + def user_permissions(self, user, obj_or_model, obj=None): """ Filter user permissions for a model class """ - - # this may be a permission for the model class, or an instance self.refresh_cache() + # this may be a permission for the model class, or an instance if isinstance(obj_or_model, ModelBase): model = obj_or_model else: diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index eb6170b6..a7496a46 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -14,6 +14,7 @@ from django.urls.resolvers import get_resolver from django.db.models import QuerySet from django.utils.datastructures import MultiValueDictKeyError from django.utils.encoding import uri_to_iri +from guardian.shortcuts import get_objects_for_user from rest_framework.exceptions import ValidationError from rest_framework.fields import SkipField, empty, ReadOnlyField from rest_framework.fields import get_error_detail, set_value @@ -96,10 +97,9 @@ class LDListMixin: parent_model = None - if isinstance(value, QuerySet): - value = list(value) + permissions_class = Model.get_permission_classes(child_model, [LDPPermissions])[0]() - if not isinstance(value, Iterable): + if not isinstance(value, Iterable) and not isinstance(value, QuerySet): if not self.id.startswith('http'): self.id = '{}{}{}'.format(settings.BASE_URL, Model.resource(parent_model), self.id) @@ -107,7 +107,6 @@ class LDListMixin: if self.with_cache and self.to_representation_cache.has(cache_key): return self.to_representation_cache.get(cache_key) - filtered_values = value container_permissions = Model.get_permissions(child_model, self.context, ['view', 'add']) else: @@ -124,19 +123,26 @@ class LDListMixin: if self.with_cache and self.to_representation_cache.has(cache_key): return self.to_representation_cache.get(cache_key) + if isinstance(value, QuerySet): + # Perf: filter on request if not a subclass of LDPPermissions + value = permissions_class.prefilter_query_set(value, self.context['request'], self.context['view'], child_model) + # remove objects from the list which I don't have permission to view - filtered_values = list( - filter(lambda v: Model.get_permission_classes(v, [LDPPermissions])[0]().has_object_permission( - self.context['request'], self.context['view'], v), value)) + # Perf: filtering is uselss in case of LDPPermissions, make sense only for subclass + # if type(permissions_class) is not LDPPermissions: + # filtered_values = list( + # filter(lambda v: permissions_class.has_object_permission( + # self.context['request'], self.context['view'], v), value)) container_permissions = Model.get_permissions(child_model, self.context, ['add']) container_permissions.extend( Model.get_permissions(parent_model, self.context, ['view'])) self.to_representation_cache.set(self.id, {'@id': self.id, - '@type': 'ldp:Container', - 'ldp:contains': super().to_representation(filtered_values), - 'permissions': container_permissions - }) + '@type': 'ldp:Container', + 'ldp:contains': super().to_representation( + value), + 'permissions': container_permissions + }) return self.to_representation_cache.get(self.id) diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index e3b1f39d..4699baab 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -9,6 +9,7 @@ from django.utils.datetime_safe import date from djangoldp.fields import LDPUrlField from djangoldp.models import Model from djangoldp.permissions import LDPPermissions +from djangoldp.tests.permissions import HalfRandomPermissions class User(AbstractUser, Model): @@ -125,6 +126,17 @@ class LDPDummy(Model): owner_perms = ['inherit', 'change', 'delete', 'control'] +class DummyAnonPerm(Model): + some = models.CharField(max_length=255, blank=True, null=True) + parent = models.ForeignKey(LDPDummy, on_delete=models.DO_NOTHING, related_name="anons", blank=True, null=True) + + class Meta(Model.Meta): + anonymous_perms = [] + authenticated_perms = ['view', 'add'] + owner_perms = ['inherit', 'change', 'delete', 'control'] + permission_classes = [HalfRandomPermissions] + + # model used in django-guardian permission tests (no anonymous etc permissions set) class PermissionlessDummy(Model): some = models.CharField(max_length=255, blank=True, null=True) diff --git a/djangoldp/tests/permissions.py b/djangoldp/tests/permissions.py new file mode 100644 index 00000000..3e9b72d6 --- /dev/null +++ b/djangoldp/tests/permissions.py @@ -0,0 +1,35 @@ +from django.db.models import QuerySet +from django.db.models.base import ModelBase + +from djangoldp.permissions import LDPPermissions + + +class HalfRandomPermissions(LDPPermissions): + + def prefilter_query_set(self, query_set: QuerySet, request, view, model) -> QuerySet: + if request.user.is_anonymous: + return query_set.filter(pk__in=[2, 4, 6, 8]) + else: + return super().prefilter_query_set(query_set, request, view, model) + + def user_permissions(self, user, obj_or_model, obj=None): + if isinstance(obj_or_model, ModelBase): + model = obj_or_model + else: + obj = obj_or_model + model = obj_or_model.__class__ + + perms_cache_key = self.cache_key(model, obj, user) + if self.with_cache and perms_cache_key in self.perms_cache: + return self.perms_cache[perms_cache_key] + + # start with the permissions set on the object and model + perms = set(super().user_permissions(user, obj_or_model, obj)) + + if obj is not None and not isinstance(obj, ModelBase) and user.is_anonymous: + if obj.pk % 2 == 0: + return ['add', 'view'] + else: + return [] + else: + return ['view'] diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index 0b51fa35..b47ae1e4 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -1,9 +1,9 @@ import json from django.contrib.auth import get_user_model from rest_framework.test import APIClient, APITestCase -from guardian.shortcuts import assign_perm +from guardian.shortcuts import assign_perm, remove_perm -from .models import PermissionlessDummy, Dummy +from .models import PermissionlessDummy, Dummy, LDPDummy, DummyAnonPerm from djangoldp.permissions import LDPPermissions @@ -53,7 +53,7 @@ class TestsGuardian(APITestCase): def test_patch_dummy_permission_granted(self): self.setUpLoggedInUser() - self.setUpGuardianDummyWithPerms(['change']) + self.setUpGuardianDummyWithPerms(['view', 'change']) body = {'some': "some_new"} response = self.client.patch('/permissionless-dummys/{}/'.format(self.dummy.slug), data=json.dumps(body), content_type='application/ld+json') @@ -88,3 +88,39 @@ class TestsGuardian(APITestCase): permissions = LDPPermissions() result = permissions.user_permissions(self.user, dummy) self.assertEqual(result.count('view'), 1) + + # test list with and without view perms + def test_get_list(self): + DummyAnonPerm.objects.create(some='test1') + DummyAnonPerm.objects.create(some='test2') + + response = self.client.get('/dummyanonperms/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + + self.setUpLoggedInUser() + + response = self.client.get('/dummyanonperms/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 2) + + def test_get_sub_list(self): + parent = LDPDummy.objects.create(some="test") + parent.anons.add(DummyAnonPerm.objects.create(some='test1')) + parent.anons.add(DummyAnonPerm.objects.create(some='test2')) + parent.save() + + response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['anons']['ldp:contains']), 1) + + self.setUpLoggedInUser() + + response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['anons']['ldp:contains']), 2) + diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 683b8168..287405b8 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -4,6 +4,7 @@ from .models import JobOffer import json + class TestUserPermissions(APITestCase): def setUp(self): diff --git a/djangoldp/views.py b/djangoldp/views.py index a5f6d990..aa77ae01 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -1,37 +1,35 @@ import json -import time +import logging + from django.apps import apps from django.conf import settings - from django.conf.urls import include, re_path from django.contrib.auth import get_user_model from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist -from django.urls.resolvers import get_resolver from django.db import IntegrityError, transaction from django.http import JsonResponse, Http404 from django.shortcuts import get_object_or_404 +from django.urls.resolvers import get_resolver from django.utils.decorators import classonlymethod from django.views import View from pyld import jsonld from rest_framework import status from rest_framework.authentication import SessionAuthentication -from rest_framework.permissions import AllowAny from rest_framework.parsers import JSONParser +from rest_framework.permissions import AllowAny from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.utils import model_meta from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet +from djangoldp.activities import ActivityPubService +from djangoldp.activities import ActivityQueueService, as_activitystream +from djangoldp.activities.errors import ActivityStreamDecodeError, ActivityStreamValidationError from djangoldp.endpoints.webfinger import WebFingerEndpoint, WebFingerError +from djangoldp.filters import LocalObjectOnContainerPathBackend from djangoldp.models import LDPSource, Model, Follower from djangoldp.permissions import LDPPermissions -from djangoldp.filters import LocalObjectOnContainerPathBackend -from djangoldp.activities import ActivityQueueService, as_activitystream -from djangoldp.activities import ActivityPubService -from djangoldp.activities.errors import ActivityStreamDecodeError, ActivityStreamValidationError -import logging - logger = logging.getLogger('djangoldp') get_user_model()._meta.rdf_context = {"get_full_name": "rdfs:label"} @@ -75,7 +73,7 @@ class InboxView(APIView): """ Receive linked data notifications """ - permission_classes=[AllowAny,] + permission_classes = [AllowAny, ] def post(self, request, *args, **kwargs): ''' @@ -94,7 +92,8 @@ class InboxView(APIView): try: self._handle_activity(activity, **kwargs) except IntegrityError: - return Response({'Unable to save due to an IntegrityError in the receiver model'}, status=status.HTTP_200_OK) + return Response({'Unable to save due to an IntegrityError in the receiver model'}, + status=status.HTTP_200_OK) # save the activity and return 201 obj = ActivityQueueService._save_sent_activity(activity.to_json(), local_id=request.path_info, success=True, @@ -126,7 +125,8 @@ class InboxView(APIView): return self._get_or_create_nested_backlinks(obj, object_model, update) except IntegrityError as e: logger.error(str(e)) - logger.warning('received a backlink which you were not able to save because of a constraint on the model field.') + logger.warning( + 'received a backlink which you were not able to save because of a constraint on the model field.') raise e def _get_or_create_nested_backlinks(self, obj, object_model=None, update=False): @@ -150,7 +150,8 @@ class InboxView(APIView): item_value = item[1] item_model = Model.get_subclass_with_rdf_type(item_value['@type']) if item_model is None: - raise Http404('unable to store type ' + item_value['@type'] + ', model with this rdf_type not found') + raise Http404( + 'unable to store type ' + item_value['@type'] + ', model with this rdf_type not found') # push nested object tuple as a branch backlink = self._get_or_create_nested_backlinks(item_value, item_model) @@ -337,6 +338,7 @@ class LDPViewSetGenerator(ModelViewSet): if view_set is not None: class LDPNestedCustomViewSet(LDPNestedViewSet, view_set): pass + return LDPNestedCustomViewSet.nested_urls(nested_field, **kwargs) return LDPNestedViewSet.nested_urls(nested_field, **kwargs) @@ -433,7 +435,8 @@ class LDPViewSet(LDPViewSetGenerator): if self.serializer_class is None: self.serializer_class = LDPSerializer - return type(LDPSerializer)(self.model._meta.object_name.lower() + name_prefix + 'Serializer', (self.serializer_class,), + return type(LDPSerializer)(self.model._meta.object_name.lower() + name_prefix + 'Serializer', + (self.serializer_class,), {'Meta': meta_class}) def is_safe_create(self, user, validated_data, *args, **kwargs): @@ -479,7 +482,8 @@ class LDPViewSet(LDPViewSetGenerator): serializer = self.get_write_serializer(data=request.data) serializer.is_valid(raise_exception=True) if not self.is_safe_create(request.user, serializer.validated_data): - return Response({'detail': 'You do not have permission to perform this action'}, status=status.HTTP_403_FORBIDDEN) + return Response({'detail': 'You do not have permission to perform this action'}, + status=status.HTTP_403_FORBIDDEN) self.perform_create(serializer) response_serializer = self.get_serializer() @@ -543,12 +547,16 @@ class LDPViewSet(LDPViewSetGenerator): def get_queryset(self, *args, **kwargs): if self.model: - return self.model.objects.all() + query_set = self.model.objects.all() + permissions_class = Model.get_permission_classes(self.model, [LDPPermissions])[0]() + return permissions_class.prefilter_query_set(query_set, self.request, None, self.model) else: return super(LDPView, self).get_queryset(*args, **kwargs) def dispatch(self, request, *args, **kwargs): '''overriden dispatch method to append some custom headers''' + # pr = cProfile.Profile() + # pr.enable() response = super(LDPViewSet, self).dispatch(request, *args, **kwargs) response["Access-Control-Allow-Origin"] = request.META.get('HTTP_ORIGIN') response["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE" @@ -566,6 +574,11 @@ class LDPViewSet(LDPViewSetGenerator): response['User'] = request.user.webid() except AttributeError: pass + # pr.disable() + # s = io.StringIO() + # ps = pstats.Stats(pr, stream=s) + # ps.print_stats() + # print(s.getvalue()) return response @@ -650,4 +663,3 @@ class WebFingerView(View): def post(self, request, *args, **kwargs): return self.on_request(request) - -- GitLab From f6409825ea5359361266434d8fdf07e137fa802b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste <bleme@pm.me> Date: Mon, 26 Oct 2020 15:27:32 +0100 Subject: [PATCH 2/8] update: avoid costly try/catch on prefilter_query_set --- djangoldp/permissions.py | 30 ++++++++++++++++++++++-------- djangoldp/tests/permissions.py | 6 +++--- djangoldp/tests/tests_guardian.py | 7 +++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 70e5c1b7..f63ecb5f 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -45,15 +45,14 @@ class LDPPermissions(DjangoObjectPermissions): :param model: the model :return: the filtered QuerySet """ - if self.has_object_permission(request, view, model): - value = query_set + if self.has_object_view_permission(request, view, model): + return query_set else: - try: + if not request.user.is_anonymous: permissions_needed = self.get_permissions(request.method, model) - value = get_objects_for_user(request.user, permissions_needed, query_set) - except: - value = [] - return query_set + return get_objects_for_user(request.user, permissions_needed, query_set) + else: + return model.objects.none() def user_permissions(self, user, obj_or_model, obj=None): """ @@ -192,9 +191,24 @@ class LDPPermissions(DjangoObjectPermissions): model = obj user_perms = self.user_permissions(request.user, model, obj) + return self.compare_permissions(perms, user_perms) + + def has_object_view_permission(self, request, view, obj): + """ + Access to objects + User have view permission on request: Continue + User does not have permission: 403 + """ + # get permissions required + perms = self.get_permissions('GET', obj) + model = obj + user_perms = self.user_permissions(request.user, model, obj) + + return self.compare_permissions(perms, user_perms) + + def compare_permissions(self, perms, user_perms): # compare them with the permissions I have for perm in perms: if not perm.split('.')[-1].split('_')[0] in user_perms: return False - return True diff --git a/djangoldp/tests/permissions.py b/djangoldp/tests/permissions.py index 3e9b72d6..f8ec6d3c 100644 --- a/djangoldp/tests/permissions.py +++ b/djangoldp/tests/permissions.py @@ -19,9 +19,9 @@ class HalfRandomPermissions(LDPPermissions): obj = obj_or_model model = obj_or_model.__class__ - perms_cache_key = self.cache_key(model, obj, user) - if self.with_cache and perms_cache_key in self.perms_cache: - return self.perms_cache[perms_cache_key] + # perms_cache_key = self.cache_key(model, obj, user) + # if self.with_cache and perms_cache_key in self.perms_cache: + # return self.perms_cache[perms_cache_key] # start with the permissions set on the object and model perms = set(super().user_permissions(user, obj_or_model, obj)) diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index b47ae1e4..6984891d 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -1,5 +1,6 @@ import json from django.contrib.auth import get_user_model +from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase from guardian.shortcuts import assign_perm, remove_perm @@ -11,11 +12,17 @@ class TestsGuardian(APITestCase): def setUp(self): self.client = APIClient(enforce_csrf_checks=True) + LDPPermissions.invalidate_cache() + LDListMixin.to_representation_cache.invalidate_cache() + LDPSerializer.to_representation_cache.invalidate_cache() def setUpLoggedInUser(self): self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', password='glass onion') self.client.force_authenticate(user=self.user) + LDPPermissions.invalidate_cache() + LDListMixin.to_representation_cache.invalidate_cache() + LDPSerializer.to_representation_cache.invalidate_cache() # optional setup for testing PermissionlessDummy model with parameterised perms def setUpGuardianDummyWithPerms(self, perms=[]): -- GitLab From 283a1f25401d7a653803e0155b150f4c414c5ad9 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Mon, 9 Nov 2020 12:06:18 +0000 Subject: [PATCH 3/8] update: get_permissions now class method, is_owner convenience function --- djangoldp/permissions.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index f63ecb5f..2087ef26 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -9,12 +9,7 @@ from rest_framework.permissions import DjangoObjectPermissions class LDPPermissions(DjangoObjectPermissions): - """ - Default permissions - Anon: None - Auth: None but inherit from Anon - Owner: None but inherit from Auth - """ + # *DEFAULT* permissions for anon, auth and owner statuses anonymous_perms = ['view'] authenticated_perms = ['inherit'] owner_perms = ['inherit'] @@ -49,11 +44,17 @@ class LDPPermissions(DjangoObjectPermissions): return query_set else: if not request.user.is_anonymous: - permissions_needed = self.get_permissions(request.method, model) + permissions_needed = LDPPermissions.get_permissions(request.method, model) return get_objects_for_user(request.user, permissions_needed, query_set) else: return model.objects.none() + def is_owner(self, user, model, obj): + return obj and hasattr(model._meta, 'owner_field') and ( + getattr(obj, getattr(model._meta, 'owner_field')) == user + or (hasattr(user, 'urlid') and getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid) + or getattr(obj, getattr(model._meta, 'owner_field')) == user.id) + def user_permissions(self, user, obj_or_model, obj=None): """ Filter user permissions for a model class @@ -101,21 +102,15 @@ class LDPPermissions(DjangoObjectPermissions): # apply anon, owner and auth permissions if user.is_anonymous: perms = perms.union(set(anonymous_perms)) - else: - if obj and hasattr(model._meta, 'owner_field') and ( - getattr(obj, getattr(model._meta, 'owner_field')) == user - or (hasattr(user, 'urlid') and getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid) - or getattr(obj, getattr(model._meta, 'owner_field')) == user.id): + if self.is_owner(user, model, obj): perms = perms.union(set(owner_perms)) - else: perms = perms.union(set(authenticated_perms)) self.perms_cache[perms_cache_key] = list(perms) return self.perms_cache[perms_cache_key] - # return list(perms) def filter_user_perms(self, context, obj_or_model, permissions): # Only used on Model.get_permissions to translate permissions to LDP @@ -132,7 +127,8 @@ class LDPPermissions(DjangoObjectPermissions): 'DELETE': ['%(app_label)s.delete_%(model_name)s'], } - def get_permissions(self, method, obj): + @classmethod + def get_permissions(cls, method, obj): """ Translate perms_map to request """ @@ -142,10 +138,10 @@ class LDPPermissions(DjangoObjectPermissions): } # Only allows methods that are on perms_map - if method not in self.perms_map: + if method not in cls.perms_map: raise PermissionDenied - return [perm % kwargs for perm in self.perms_map[method]] + return [perm % kwargs for perm in cls.perms_map[method]] def has_permission(self, request, view): """ @@ -165,7 +161,7 @@ class LDPPermissions(DjangoObjectPermissions): model = view.model # get permissions required - perms = self.get_permissions(request.method, model) + perms = LDPPermissions.get_permissions(request.method, model) user_perms = self.user_permissions(request.user, model, obj) # compare them with the permissions I have @@ -187,7 +183,7 @@ class LDPPermissions(DjangoObjectPermissions): User does not have permission: 403 """ # get permissions required - perms = self.get_permissions(request.method, obj) + perms = LDPPermissions.get_permissions(request.method, obj) model = obj user_perms = self.user_permissions(request.user, model, obj) @@ -200,7 +196,7 @@ class LDPPermissions(DjangoObjectPermissions): User does not have permission: 403 """ # get permissions required - perms = self.get_permissions('GET', obj) + perms = LDPPermissions.get_permissions('GET', obj) model = obj user_perms = self.user_permissions(request.user, model, obj) -- GitLab From 0da697245c67c6627a84b68bd870c5762310405f Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Mon, 9 Nov 2020 12:17:05 +0000 Subject: [PATCH 4/8] syntax: cleaning user_perms function --- djangoldp/permissions.py | 66 ++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 2087ef26..9a3e2463 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -55,6 +55,33 @@ class LDPPermissions(DjangoObjectPermissions): or (hasattr(user, 'urlid') and getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid) or getattr(obj, getattr(model._meta, 'owner_field')) == user.id) + def _get_cache_key(self, model_name, user, obj): + user_key = 'None' if user is None else user.id + obj_key = 'None' if obj is None else obj.id + return 'User{}{}{}'.format(user_key, model_name, obj_key) + + def _get_model_level_perms(self, model, user, obj): + anonymous_perms = getattr(model._meta, 'anonymous_perms', self.anonymous_perms) + authenticated_perms = getattr(model._meta, 'authenticated_perms', self.authenticated_perms) + owner_perms = getattr(model._meta, 'owner_perms', self.owner_perms) + + # 'inherit' permissions means inherit the permissions from the next level 'down' + if 'inherit' in authenticated_perms: + authenticated_perms = authenticated_perms + list(set(anonymous_perms) - set(authenticated_perms)) + if 'inherit' in owner_perms: + owner_perms = owner_perms + list(set(authenticated_perms) - set(owner_perms)) + + # apply user permissions and return + perms = set() + if user.is_anonymous: + perms = perms.union(set(anonymous_perms)) + else: + if self.is_owner(user, model, obj): + perms = perms.union(set(owner_perms)) + else: + perms = perms.union(set(authenticated_perms)) + return perms + def user_permissions(self, user, obj_or_model, obj=None): """ Filter user permissions for a model class @@ -66,47 +93,20 @@ class LDPPermissions(DjangoObjectPermissions): else: obj = obj_or_model model = obj_or_model.__class__ - model_name = model._meta.model_name - user_key = 'None' if user is None else user.id - obj_key = 'None' if obj is None else obj.id - perms_cache_key = 'User{}{}{}'.format(user_key, model_name, obj_key) - if self.with_cache and perms_cache_key in self.perms_cache: - return self.perms_cache[perms_cache_key] - # Get Anonymous permissions from Model's Meta. If not found use default - anonymous_perms = getattr(model._meta, 'anonymous_perms', self.anonymous_perms) - - # Get Auth permissions from Model's Meta. If not found use default - authenticated_perms = getattr(model._meta, 'authenticated_perms', self.authenticated_perms) - # Extend Auth if inherit is given - if 'inherit' in authenticated_perms: - authenticated_perms = authenticated_perms + list(set(anonymous_perms) - set(authenticated_perms)) - - # Get Owner permissions from Model's Meta. If not found use default - owner_perms = getattr(model._meta, 'owner_perms', self.owner_perms) - # Extend Owner if inherit is given - if 'inherit' in owner_perms: - owner_perms = owner_perms + list(set(authenticated_perms) - set(owner_perms)) + perms_cache_key = self._get_cache_key(model_name, user, obj) + if self.with_cache: + if perms_cache_key in self.perms_cache: + return self.perms_cache[perms_cache_key] # return permissions - using set to avoid duplicates - # apply Django-Guardian (object-level) permissions - perms = set() + perms = self._get_model_level_perms(model, user, obj) if obj is not None and not user.is_anonymous: # get permissions from all backends and then remove model name from the permissions - model_name = model._meta.model_name forbidden_string = "_" + model_name - perms = set([p.replace(forbidden_string, '') for p in _user_get_all_permissions(user, obj)]) - - # apply anon, owner and auth permissions - if user.is_anonymous: - perms = perms.union(set(anonymous_perms)) - else: - if self.is_owner(user, model, obj): - perms = perms.union(set(owner_perms)) - else: - perms = perms.union(set(authenticated_perms)) + perms = perms.union(set([p.replace(forbidden_string, '') for p in _user_get_all_permissions(user, obj)])) self.perms_cache[perms_cache_key] = list(perms) -- GitLab From 2118afa5b1dbe34b466a4a0779acfa758a342a54 Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Tue, 10 Nov 2020 07:34:01 +0000 Subject: [PATCH 5/8] update: prefilter moved to LDPViewSet.filter_queryset --- README.md | 2 - djangoldp/permissions.py | 63 ++++++++--------------- djangoldp/serializers.py | 12 ----- djangoldp/tests/models.py | 12 +---- djangoldp/tests/tests_guardian.py | 62 +++++++++++----------- djangoldp/tests/tests_user_permissions.py | 29 ++++++++++- djangoldp/views.py | 26 +++++----- 7 files changed, 94 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 932ae167..cf2760d5 100644 --- a/README.md +++ b/README.md @@ -337,8 +337,6 @@ This allows you to add permissions for anonymous, logged in user, author ... in By default `LDPPermissions` is used. Specific permissin classes can be developed to fit special needs. -The method `prefilter_query_s_set()` should be override and must add a filter to the query_set removing object without at least the `view` permission - ## anonymous_perms, user_perms, owner_perms Those allow you to set permissions from your model's meta. diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 9a3e2463..68a251cf 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -2,9 +2,7 @@ import time from django.conf import settings from django.contrib.auth.models import _user_get_all_permissions from django.core.exceptions import PermissionDenied -from django.db.models import QuerySet from django.db.models.base import ModelBase -from guardian.shortcuts import get_objects_for_user from rest_framework.permissions import DjangoObjectPermissions @@ -30,26 +28,8 @@ class LDPPermissions(DjangoObjectPermissions): if time.time() - cls.perms_cache['time'] > 5: cls.invalidate_cache() - def prefilter_query_set(self, query_set, request, view, model) -> QuerySet: - """ - Must filter object that user doesn't have at least the `view` permission - Should reflect user_permissions - :param query_set: the base QuerySet - :param request: the request - :param view: the view - :param model: the model - :return: the filtered QuerySet - """ - if self.has_object_view_permission(request, view, model): - return query_set - else: - if not request.user.is_anonymous: - permissions_needed = LDPPermissions.get_permissions(request.method, model) - return get_objects_for_user(request.user, permissions_needed, query_set) - else: - return model.objects.none() - - def is_owner(self, user, model, obj): + @classmethod + def is_owner(cls, user, model, obj): return obj and hasattr(model._meta, 'owner_field') and ( getattr(obj, getattr(model._meta, 'owner_field')) == user or (hasattr(user, 'urlid') and getattr(obj, getattr(model._meta, 'owner_field')) == user.urlid) @@ -60,10 +40,12 @@ class LDPPermissions(DjangoObjectPermissions): obj_key = 'None' if obj is None else obj.id return 'User{}{}{}'.format(user_key, model_name, obj_key) - def _get_model_level_perms(self, model, user, obj): - anonymous_perms = getattr(model._meta, 'anonymous_perms', self.anonymous_perms) - authenticated_perms = getattr(model._meta, 'authenticated_perms', self.authenticated_perms) - owner_perms = getattr(model._meta, 'owner_perms', self.owner_perms) + @classmethod + def get_model_level_perms(cls, model, user, obj=None): + '''Auxiliary function returns the model-level anon-auth-owner permissions for a given, model, user and object''' + anonymous_perms = getattr(model._meta, 'anonymous_perms', cls.anonymous_perms) + authenticated_perms = getattr(model._meta, 'authenticated_perms', cls.authenticated_perms) + owner_perms = getattr(model._meta, 'owner_perms', cls.owner_perms) # 'inherit' permissions means inherit the permissions from the next level 'down' if 'inherit' in authenticated_perms: @@ -76,7 +58,7 @@ class LDPPermissions(DjangoObjectPermissions): if user.is_anonymous: perms = perms.union(set(anonymous_perms)) else: - if self.is_owner(user, model, obj): + if cls.is_owner(user, model, obj): perms = perms.union(set(owner_perms)) else: perms = perms.union(set(authenticated_perms)) @@ -101,7 +83,7 @@ class LDPPermissions(DjangoObjectPermissions): return self.perms_cache[perms_cache_key] # return permissions - using set to avoid duplicates - perms = self._get_model_level_perms(model, user, obj) + perms = self.get_model_level_perms(model, user, obj) if obj is not None and not user.is_anonymous: # get permissions from all backends and then remove model name from the permissions @@ -187,21 +169,20 @@ class LDPPermissions(DjangoObjectPermissions): model = obj user_perms = self.user_permissions(request.user, model, obj) - return self.compare_permissions(perms, user_perms) + return LDPPermissions.compare_permissions(perms, user_perms) - def has_object_view_permission(self, request, view, obj): - """ - Access to objects - User have view permission on request: Continue - User does not have permission: 403 - """ - # get permissions required - perms = LDPPermissions.get_permissions('GET', obj) - model = obj - user_perms = self.user_permissions(request.user, model, obj) - - return self.compare_permissions(perms, user_perms) + @classmethod + def has_model_view_permission(cls, request, model): + ''' + shortcut to compare the requested user's permissions on the model-level + :return: True or False + ''' + # compare required permissions with those I have (on the model) + perms = LDPPermissions.get_permissions('GET', model) + user_perms = LDPPermissions.get_model_level_perms(model, request.user) + return cls.compare_permissions(perms, user_perms) + @classmethod def compare_permissions(self, perms, user_perms): # compare them with the permissions I have for perm in perms: diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index a7496a46..ecd1b3d6 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -97,8 +97,6 @@ class LDListMixin: parent_model = None - permissions_class = Model.get_permission_classes(child_model, [LDPPermissions])[0]() - if not isinstance(value, Iterable) and not isinstance(value, QuerySet): if not self.id.startswith('http'): self.id = '{}{}{}'.format(settings.BASE_URL, Model.resource(parent_model), self.id) @@ -123,16 +121,6 @@ class LDListMixin: if self.with_cache and self.to_representation_cache.has(cache_key): return self.to_representation_cache.get(cache_key) - if isinstance(value, QuerySet): - # Perf: filter on request if not a subclass of LDPPermissions - value = permissions_class.prefilter_query_set(value, self.context['request'], self.context['view'], child_model) - - # remove objects from the list which I don't have permission to view - # Perf: filtering is uselss in case of LDPPermissions, make sense only for subclass - # if type(permissions_class) is not LDPPermissions: - # filtered_values = list( - # filter(lambda v: permissions_class.has_object_permission( - # self.context['request'], self.context['view'], v), value)) container_permissions = Model.get_permissions(child_model, self.context, ['add']) container_permissions.extend( Model.get_permissions(parent_model, self.context, ['view'])) diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index 4699baab..b576ceb2 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -126,21 +126,11 @@ class LDPDummy(Model): owner_perms = ['inherit', 'change', 'delete', 'control'] -class DummyAnonPerm(Model): - some = models.CharField(max_length=255, blank=True, null=True) - parent = models.ForeignKey(LDPDummy, on_delete=models.DO_NOTHING, related_name="anons", blank=True, null=True) - - class Meta(Model.Meta): - anonymous_perms = [] - authenticated_perms = ['view', 'add'] - owner_perms = ['inherit', 'change', 'delete', 'control'] - permission_classes = [HalfRandomPermissions] - - # model used in django-guardian permission tests (no anonymous etc permissions set) class PermissionlessDummy(Model): some = models.CharField(max_length=255, blank=True, null=True) slug = models.SlugField(blank=True, null=True, unique=True) + parent = models.ForeignKey(LDPDummy, on_delete=models.DO_NOTHING, related_name="anons", blank=True, null=True) class Meta(Model.Meta): anonymous_perms = [] diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index 6984891d..1ceb9b2a 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -1,10 +1,11 @@ import json +import uuid from django.contrib.auth import get_user_model from djangoldp.serializers import LDListMixin, LDPSerializer from rest_framework.test import APIClient, APITestCase -from guardian.shortcuts import assign_perm, remove_perm +from guardian.shortcuts import assign_perm -from .models import PermissionlessDummy, Dummy, LDPDummy, DummyAnonPerm +from .models import PermissionlessDummy, Dummy, LDPDummy from djangoldp.permissions import LDPPermissions @@ -24,13 +25,20 @@ class TestsGuardian(APITestCase): LDListMixin.to_representation_cache.invalidate_cache() LDPSerializer.to_representation_cache.invalidate_cache() - # optional setup for testing PermissionlessDummy model with parameterised perms - def setUpGuardianDummyWithPerms(self, perms=[]): - self.dummy = PermissionlessDummy.objects.create(some='test', slug='test') + def _get_dummy_with_perms(self, perms=None, parent=None): + if perms is None: + perms = [] + dummy = PermissionlessDummy.objects.create(some='test', slug=uuid.uuid4(), parent=parent) model_name = PermissionlessDummy._meta.model_name for perm in perms: - assign_perm(perm + '_' + model_name, self.user, self.dummy) + assign_perm(perm + '_' + model_name, self.user, dummy) + + return dummy + + # optional setup for testing PermissionlessDummy model with parameterised perms + def setUpGuardianDummyWithPerms(self, perms=None, parent=None): + self.dummy = self._get_dummy_with_perms(perms, parent) # test that dummy with no permissions set returns no results def test_get_dummy_no_permissions(self): @@ -96,38 +104,26 @@ class TestsGuardian(APITestCase): result = permissions.user_permissions(self.user, dummy) self.assertEqual(result.count('view'), 1) - # test list with and without view perms - def test_get_list(self): - DummyAnonPerm.objects.create(some='test1') - DummyAnonPerm.objects.create(some='test2') - - response = self.client.get('/dummyanonperms/') - + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/297 + '''def test_list_dummy_exception(self): + self.setUpLoggedInUser() + # I have permission on a permissionless dummy, but not in general + dummy_a = self._get_dummy_with_perms() + dummy_b = self._get_dummy_with_perms(['view']) + response = self.client.get('/permissionless-dummys/') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data['ldp:contains']), 1) + self.assertNotIn(response.data['ldp:contains'], dummy_a.urlid) + self.assertIn(response.data['ldp:contains'], dummy_b.urlid)''' + # TODO: + '''def test_list_dummy_exception_nested(self): self.setUpLoggedInUser() - - response = self.client.get('/dummyanonperms/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['ldp:contains']), 2) - - def test_get_sub_list(self): parent = LDPDummy.objects.create(some="test") - parent.anons.add(DummyAnonPerm.objects.create(some='test1')) - parent.anons.add(DummyAnonPerm.objects.create(some='test2')) - parent.save() - - response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['anons']['ldp:contains']), 1) - - self.setUpLoggedInUser() - + # two dummies, one I have permission to view and one I don't + dummy_a = self._get_dummy_with_perms(parent=parent) + dummy_b = self._get_dummy_with_perms(['view'], parent) response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['anons']['ldp:contains']), 2) + self.assertEqual(len(response.data['anons']['ldp:contains']), 1)''' diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 287405b8..6f9fe195 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission, Group from rest_framework.test import APIClient, APITestCase -from .models import JobOffer +from .models import JobOffer, LDPDummy, PermissionlessDummy import json @@ -13,10 +14,36 @@ class TestUserPermissions(APITestCase): self.client.force_authenticate(user=self.user) self.job = JobOffer.objects.create(title="job", slug="slug1") + def setUpGroup(self): + self.group = Group.objects.create(name='Test') + view_perm = Permission.objects.get(codename='view_permissionlessdummy') + self.group.permissions.add(view_perm) + self.group.save() + + # list - simple def test_get_for_authenticated_user(self): response = self.client.get('/job-offers/') self.assertEqual(response.status_code, 200) + # list - I do not have permission from the model, but I do have permission via a Group I am assigned + # TODO: https://git.startinblox.com/djangoldp-packages/djangoldp/issues/291 + '''def test_group_list_access(self): + self.setUpGroup() + + response = self.client.get('/permissionless-dummys/') + self.assertEqual(response.status_code, 403) + + self.user.groups.add(self.group) + self.user.save() + response = self.client.get('/permissionless-dummys/') + self.assertEqual(response.status_code, 200)''' + + # repeat of the above test on nested field + '''def test_group_list_access_nested(self): + self.setUpGroup() + parent = LDPDummy.objects.create() + dummy = PermissionlessDummy.objects.create(parent=parent)''' + def test_get_1_for_authenticated_user(self): response = self.client.get('/job-offers/{}/'.format(self.job.slug)) self.assertEqual(response.status_code, 200) diff --git a/djangoldp/views.py b/djangoldp/views.py index aa77ae01..6c01c35c 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -22,6 +22,7 @@ from rest_framework.response import Response from rest_framework.utils import model_meta from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet +from guardian.shortcuts import get_objects_for_user from djangoldp.activities import ActivityPubService from djangoldp.activities import ActivityQueueService, as_activitystream @@ -546,17 +547,23 @@ class LDPViewSet(LDPViewSetGenerator): serializer.save(**kwargs) def get_queryset(self, *args, **kwargs): - if self.model: - query_set = self.model.objects.all() - permissions_class = Model.get_permission_classes(self.model, [LDPPermissions])[0]() - return permissions_class.prefilter_query_set(query_set, self.request, None, self.model) + return self.model.objects.all() + + def filter_queryset(self, queryset): + queryset = super(LDPViewSet, self).filter_queryset(queryset) + + # compares the requirement for GET, with what the user has on the MODEL + if LDPPermissions.has_model_view_permission(self.request, self.model): + return queryset else: - return super(LDPView, self).get_queryset(*args, **kwargs) + if not self.request.user.is_anonymous: + permissions_needed = LDPPermissions.get_permissions(self.request.method, self.model) + return get_objects_for_user(self.request.user, permissions_needed, queryset) + else: + return self.model.objects.none() def dispatch(self, request, *args, **kwargs): '''overriden dispatch method to append some custom headers''' - # pr = cProfile.Profile() - # pr.enable() response = super(LDPViewSet, self).dispatch(request, *args, **kwargs) response["Access-Control-Allow-Origin"] = request.META.get('HTTP_ORIGIN') response["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE" @@ -574,11 +581,6 @@ class LDPViewSet(LDPViewSetGenerator): response['User'] = request.user.webid() except AttributeError: pass - # pr.disable() - # s = io.StringIO() - # ps = pstats.Stats(pr, stream=s) - # ps.print_stats() - # print(s.getvalue()) return response -- GitLab From 15046c1bbe2060d979c8fb4f1a2f7b3ff9847d5e Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Wed, 11 Nov 2020 13:43:41 +0000 Subject: [PATCH 6/8] minor: switched to filter_backends based system --- README.md | 14 ++++--- djangoldp/filters.py | 26 +++++++++++- djangoldp/models.py | 26 ++++++++++++ djangoldp/permissions.py | 4 ++ djangoldp/serializers.py | 6 ++- djangoldp/tests/tests_guardian.py | 15 +++++-- djangoldp/tests/tests_user_permissions.py | 2 +- djangoldp/views.py | 51 ++--------------------- 8 files changed, 84 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index cf2760d5..442a2ecc 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ In the following example, besides the urls `/members/` and `/members/<pk>/`, two ## Filter Backends +### LocalObjectFilterBackend: Excluding distant resources + To achieve federation, DjangoLDP includes links to objects from federated servers and stores these as local objects (see 1.0 - Models). In some situations, you will want to exclude these from the queryset of a custom view To provide for this need, there is defined in `djangoldp.filters` a FilterBackend which can be included in custom viewsets to restrict the queryset to only objects which were created locally: @@ -327,17 +329,17 @@ class MyModel(models.Model): Now when an instance of `MyModel` is saved, its `author_user` property will be set to the **profile** of the authenticated user. -## permissions - -Django-Guardian is used by default to support object-level permissions. Custom permissions can be added to your model using this attribute. See the [Django-Guardian documentation](https://django-guardian.readthedocs.io/en/stable/userguide/assign.html) for more information - -## permissions_classes +### permissions_classes This allows you to add permissions for anonymous, logged in user, author ... in the url: By default `LDPPermissions` is used. Specific permissin classes can be developed to fit special needs. -## anonymous_perms, user_perms, owner_perms +For developing custom permissions classes using `LDPPermissions`, please see the DjangoLDP [guide on permisssions](https://git.startinblox.com/djangoldp-packages/djangoldp/wikis/guides/custom-permissions) + +Django-Guardian is used by default to support object-level permissions. Custom permissions can be added to your model using this attribute. See the [Django-Guardian documentation](https://django-guardian.readthedocs.io/en/stable/userguide/assign.html) for more information + +### anonymous_perms, user_perms, owner_perms Those allow you to set permissions from your model's meta. diff --git a/djangoldp/filters.py b/djangoldp/filters.py index 59afd6b9..c002770a 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -1,5 +1,25 @@ +from guardian.shortcuts import get_objects_for_user from rest_framework.filters import BaseFilterBackend -from djangoldp.models import Model + + +class LDPPermissionsFilterBackend(BaseFilterBackend): + """ + Default FilterBackend for LDPPermissions. If user does not have model-level permissions, filters by + Django-Guardian's get_objects_for_user + """ + def filter_queryset(self, request, queryset, view): + from djangoldp.permissions import LDPPermissions + + # compares the requirement for GET, with what the user has on the MODEL + if LDPPermissions.has_model_view_permission(request, view.model): + return queryset + else: + if not request.user.is_anonymous: + permissions_needed = LDPPermissions.get_permissions(request.method, view.model) + queryset = get_objects_for_user(request.user, permissions_needed, queryset) + return queryset + else: + return view.model.objects.none() class LocalObjectFilterBackend(BaseFilterBackend): @@ -8,6 +28,8 @@ class LocalObjectFilterBackend(BaseFilterBackend): For querysets which should only include local objects """ def filter_queryset(self, request, queryset, view): + from djangoldp.models import Model + internal_ids = [x.pk for x in queryset if not Model.is_external(x)] return queryset.filter(pk__in=internal_ids) @@ -18,6 +40,8 @@ class LocalObjectOnContainerPathBackend(LocalObjectFilterBackend): is the model container path """ def filter_queryset(self, request, queryset, view): + from djangoldp.models import Model + if issubclass(view.model, Model) and request.path_info == view.model.get_container_path(): return super(LocalObjectOnContainerPathBackend, self).filter_queryset(request, queryset, view) return queryset diff --git a/djangoldp/models.py b/djangoldp/models.py index 7e2a485b..2c44edb1 100644 --- a/djangoldp/models.py +++ b/djangoldp/models.py @@ -59,6 +59,32 @@ class Model(models.Model): def __init__(self, *args, **kwargs): super(Model, self).__init__(*args, **kwargs) + @classmethod + def filter_backends(cls): + '''constructs a list of filter_backends configured on the permissions classes applied to this model''' + filtered_classes = [p for p in cls.get_permission_classes(cls, [LDPPermissions]) if + hasattr(p, 'filter_backends') and p.filter_backends is not None] + filter_backends = list() + for p in filtered_classes: + filter_backends = list(set(filter_backends).union(set(p.filter_backends))) + return filter_backends + + @classmethod + def get_queryset(cls, request, view, queryset=None, model=None): + ''' + when serializing as a child of another resource (my model has a many-to-one relationship with some parent), + get_queryset is used to obtain the resources which should be displayed. This allows us to exclude those objects + which I do not have permission to view in an automatically generated serializer + ''' + if queryset is None: + queryset = cls.objects.all() + # this is a hack - sorry! https://git.startinblox.com/djangoldp-packages/djangoldp/issues/301/ + if model is not None: + view.model = model + for backend in list(cls.filter_backends()): + queryset = backend().filter_queryset(request, queryset, view) + return queryset + @classmethod def get_view_set(cls): '''returns the view_set defined in the model Meta or the LDPViewSet class''' diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 68a251cf..3f9a2d61 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -4,6 +4,7 @@ from django.contrib.auth.models import _user_get_all_permissions from django.core.exceptions import PermissionDenied from django.db.models.base import ModelBase from rest_framework.permissions import DjangoObjectPermissions +from djangoldp.filters import LDPPermissionsFilterBackend class LDPPermissions(DjangoObjectPermissions): @@ -11,6 +12,9 @@ class LDPPermissions(DjangoObjectPermissions): anonymous_perms = ['view'] authenticated_perms = ['inherit'] owner_perms = ['inherit'] + # filter backends associated with the permissions class. This will be used to filter queryset in the (auto-generated) + # view for a model, and in the serializing nested fields + filter_backends = [LDPPermissionsFilterBackend] perms_cache = { 'time': time.time() diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index ecd1b3d6..e37e3358 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -14,7 +14,6 @@ from django.urls.resolvers import get_resolver from django.db.models import QuerySet from django.utils.datastructures import MultiValueDictKeyError from django.utils.encoding import uri_to_iri -from guardian.shortcuts import get_objects_for_user from rest_framework.exceptions import ValidationError from rest_framework.fields import SkipField, empty, ReadOnlyField from rest_framework.fields import get_error_detail, set_value @@ -121,6 +120,11 @@ class LDListMixin: if self.with_cache and self.to_representation_cache.has(cache_key): return self.to_representation_cache.get(cache_key) + # filter the queryset automatically based on child model permissions classes (filter_backends) + if isinstance(value, QuerySet) and hasattr(child_model, 'get_queryset'): + value = child_model.get_queryset(self.context['request'], self.context['view'], queryset=value, + model=child_model) + container_permissions = Model.get_permissions(child_model, self.context, ['add']) container_permissions.extend( Model.get_permissions(parent_model, self.context, ['view'])) diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index 1ceb9b2a..c66369f3 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -116,8 +116,17 @@ class TestsGuardian(APITestCase): self.assertNotIn(response.data['ldp:contains'], dummy_a.urlid) self.assertIn(response.data['ldp:contains'], dummy_b.urlid)''' - # TODO: - '''def test_list_dummy_exception_nested(self): + def test_list_dummy_exception_nested_view(self): + self.setUpLoggedInUser() + parent = LDPDummy.objects.create(some="test") + # two dummies, one I have permission to view and one I don't + dummy_a = self._get_dummy_with_perms(parent=parent) + dummy_b = self._get_dummy_with_perms(['view'], parent) + response = self.client.get('/ldpdummys/{}/anons/'.format(parent.pk)) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['ldp:contains']), 1) + + def test_list_dummy_exception_nested_serializer(self): self.setUpLoggedInUser() parent = LDPDummy.objects.create(some="test") # two dummies, one I have permission to view and one I don't @@ -125,5 +134,5 @@ class TestsGuardian(APITestCase): dummy_b = self._get_dummy_with_perms(['view'], parent) response = self.client.get('/ldpdummys/{}/'.format(parent.pk)) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['anons']['ldp:contains']), 1)''' + self.assertEqual(len(response.data['anons']['ldp:contains']), 1) diff --git a/djangoldp/tests/tests_user_permissions.py b/djangoldp/tests/tests_user_permissions.py index 6f9fe195..2f61261c 100644 --- a/djangoldp/tests/tests_user_permissions.py +++ b/djangoldp/tests/tests_user_permissions.py @@ -38,7 +38,7 @@ class TestUserPermissions(APITestCase): response = self.client.get('/permissionless-dummys/') self.assertEqual(response.status_code, 200)''' - # repeat of the above test on nested field + # TODO: repeat of the above test on nested field '''def test_group_list_access_nested(self): self.setUpGroup() parent = LDPDummy.objects.create() diff --git a/djangoldp/views.py b/djangoldp/views.py index 6c01c35c..61ee5e05 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -22,7 +22,6 @@ from rest_framework.response import Response from rest_framework.utils import model_meta from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet -from guardian.shortcuts import get_objects_for_user from djangoldp.activities import ActivityPubService from djangoldp.activities import ActivityQueueService, as_activitystream @@ -393,9 +392,9 @@ class LDPViewSet(LDPViewSetGenerator): # attach filter backends based on permissions classes, to reduce the queryset based on these permissions # https://www.django-rest-framework.org/api-guide/filtering/#generic-filtering if self.permission_classes: - for p in self.permission_classes: - if hasattr(p, 'filter_class') and p.filter_class: - self.filter_backends.append(p.filter_class) + filtered_classes = [p for p in self.permission_classes if hasattr(p, 'filter_backends') and p.filter_backends is not None] + for p in filtered_classes: + self.filter_backends = list(set(self.filter_backends).union(set(p.filter_backends))) self.serializer_class = self.build_read_serializer() self.write_serializer_class = self.build_write_serializer() @@ -448,37 +447,6 @@ class LDPViewSet(LDPViewSetGenerator): ''' return True - # def list(self, request, *args, **kwargs): - # t1 = time.time() - # queryset = self.get_queryset() - # t2 = time.time() - # print('got queryset in ' + str(t2 - t1)) - # - # t1 = time.time() - # queryset = self.filter_queryset(queryset) - # t2 = time.time() - # print('filtered queryset in ' + str(t2 - t1)) - # - # t1 = time.time() - # page = self.paginate_queryset(queryset) - # t2 = time.time() - # print('paginated queryset in ' + str(t2-t1)) - # if page is not None: - # t1 = time.time() - # serializer = self.get_serializer(page, many=True) - # paginated_response = self.get_paginated_response(serializer.data) - # t2 = time.time() - # print('paginated response in ' + str(t2-t1)) - # - # return paginated_response - # - # t1 = time.time() - # serializer = self.get_serializer(queryset, many=True) - # response = Response(serializer.data) - # t2 = time.time() - # print('regular response in ' + str(t2-t1)) - # return response - def create(self, request, *args, **kwargs): serializer = self.get_write_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -549,19 +517,6 @@ class LDPViewSet(LDPViewSetGenerator): def get_queryset(self, *args, **kwargs): return self.model.objects.all() - def filter_queryset(self, queryset): - queryset = super(LDPViewSet, self).filter_queryset(queryset) - - # compares the requirement for GET, with what the user has on the MODEL - if LDPPermissions.has_model_view_permission(self.request, self.model): - return queryset - else: - if not self.request.user.is_anonymous: - permissions_needed = LDPPermissions.get_permissions(self.request.method, self.model) - return get_objects_for_user(self.request.user, permissions_needed, queryset) - else: - return self.model.objects.none() - def dispatch(self, request, *args, **kwargs): '''overriden dispatch method to append some custom headers''' response = super(LDPViewSet, self).dispatch(request, *args, **kwargs) -- GitLab From b403007593b85d0c426b12acd2e413657f76dc3d Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Thu, 12 Nov 2020 15:09:18 +0000 Subject: [PATCH 7/8] update: adapted for new cache --- djangoldp/serializers.py | 3 --- djangoldp/tests/tests_guardian.py | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 2ce691e0..28a8ad51 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -105,9 +105,6 @@ class LDListMixin: cache_vary = str(self.context['request'].user) - if isinstance(value, QuerySet): - value = list(value) - if not isinstance(value, Iterable): if not self.id.startswith('http'): self.id = '{}{}{}'.format(settings.BASE_URL, Model.resource(parent_model), self.id) diff --git a/djangoldp/tests/tests_guardian.py b/djangoldp/tests/tests_guardian.py index c66369f3..79a770fd 100644 --- a/djangoldp/tests/tests_guardian.py +++ b/djangoldp/tests/tests_guardian.py @@ -14,16 +14,16 @@ class TestsGuardian(APITestCase): def setUp(self): self.client = APIClient(enforce_csrf_checks=True) LDPPermissions.invalidate_cache() - LDListMixin.to_representation_cache.invalidate_cache() - LDPSerializer.to_representation_cache.invalidate_cache() + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() def setUpLoggedInUser(self): self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com', password='glass onion') self.client.force_authenticate(user=self.user) LDPPermissions.invalidate_cache() - LDListMixin.to_representation_cache.invalidate_cache() - LDPSerializer.to_representation_cache.invalidate_cache() + LDListMixin.to_representation_cache.reset() + LDPSerializer.to_representation_cache.reset() def _get_dummy_with_perms(self, perms=None, parent=None): if perms is None: -- GitLab From d62014924ff5446f2ffd26247cba4c8ebb4455da Mon Sep 17 00:00:00 2001 From: Calum Mackervoy <c.mackervoy@gmail.com> Date: Thu, 12 Nov 2020 15:48:41 +0000 Subject: [PATCH 8/8] update: using ObjectPermissionsFilter --- djangoldp/filters.py | 15 ++++++--------- djangoldp/serializers.py | 2 +- djangoldp/tests/settings_default.py | 1 + setup.cfg | 1 + 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/djangoldp/filters.py b/djangoldp/filters.py index c002770a..b9c9bd01 100644 --- a/djangoldp/filters.py +++ b/djangoldp/filters.py @@ -1,8 +1,8 @@ -from guardian.shortcuts import get_objects_for_user from rest_framework.filters import BaseFilterBackend +from rest_framework_guardian.filters import ObjectPermissionsFilter -class LDPPermissionsFilterBackend(BaseFilterBackend): +class LDPPermissionsFilterBackend(ObjectPermissionsFilter): """ Default FilterBackend for LDPPermissions. If user does not have model-level permissions, filters by Django-Guardian's get_objects_for_user @@ -13,13 +13,10 @@ class LDPPermissionsFilterBackend(BaseFilterBackend): # compares the requirement for GET, with what the user has on the MODEL if LDPPermissions.has_model_view_permission(request, view.model): return queryset - else: - if not request.user.is_anonymous: - permissions_needed = LDPPermissions.get_permissions(request.method, view.model) - queryset = get_objects_for_user(request.user, permissions_needed, queryset) - return queryset - else: - return view.model.objects.none() + if not request.user.is_anonymous: + return super().filter_queryset(request, queryset, view) + # user is anonymous without anonymous permissions + return view.model.objects.none() class LocalObjectFilterBackend(BaseFilterBackend): diff --git a/djangoldp/serializers.py b/djangoldp/serializers.py index 28a8ad51..89af7347 100644 --- a/djangoldp/serializers.py +++ b/djangoldp/serializers.py @@ -105,7 +105,7 @@ class LDListMixin: cache_vary = str(self.context['request'].user) - if not isinstance(value, Iterable): + if not isinstance(value, Iterable) and not isinstance(value, QuerySet): if not self.id.startswith('http'): self.id = '{}{}{}'.format(settings.BASE_URL, Model.resource(parent_model), self.id) diff --git a/djangoldp/tests/settings_default.py b/djangoldp/tests/settings_default.py index 3686e777..2634324c 100644 --- a/djangoldp/tests/settings_default.py +++ b/djangoldp/tests/settings_default.py @@ -93,3 +93,4 @@ LDP_RDF_CONTEXT={ } SEND_BACKLINKS=False GUARDIAN_AUTO_PREFETCH = True +SERIALIZER_CACHE = False diff --git a/setup.cfg b/setup.cfg index 45f79b19..ae8396a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,6 +19,7 @@ install_requires = pyld==1.0.5 django-guardian==2.3.0 django-brotli + djangorestframework-guardian [options.extras_require] dev = -- GitLab